1use crate::rule::Rule;
6use crate::rules;
7use lazy_static::lazy_static;
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::path::Path;
16use std::str::FromStr;
17use toml_edit::DocumentMut;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, schemars::JsonSchema)]
21#[serde(rename_all = "lowercase")]
22pub enum MarkdownFlavor {
23 #[serde(rename = "standard", alias = "none", alias = "")]
25 #[default]
26 Standard,
27 #[serde(rename = "mkdocs")]
29 MkDocs,
30 }
34
35impl fmt::Display for MarkdownFlavor {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 match self {
38 MarkdownFlavor::Standard => write!(f, "standard"),
39 MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
40 }
41 }
42}
43
44impl FromStr for MarkdownFlavor {
45 type Err = String;
46
47 fn from_str(s: &str) -> Result<Self, Self::Err> {
48 match s.to_lowercase().as_str() {
49 "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
50 "mkdocs" => Ok(MarkdownFlavor::MkDocs),
51 "gfm" | "github" => {
53 eprintln!("Warning: GFM flavor not yet implemented, using standard");
54 Ok(MarkdownFlavor::Standard)
55 }
56 "commonmark" => {
57 eprintln!("Warning: CommonMark flavor not yet implemented, using standard");
58 Ok(MarkdownFlavor::Standard)
59 }
60 _ => Err(format!("Unknown markdown flavor: {s}")),
61 }
62 }
63}
64
65lazy_static! {
66 static ref MARKDOWNLINT_KEY_MAP: HashMap<&'static str, &'static str> = {
68 let mut m = HashMap::new();
69 m.insert("ul-style", "md004");
72 m.insert("code-block-style", "md046");
73 m.insert("ul-indent", "md007"); m.insert("line-length", "md013"); m
77 };
78}
79
80pub fn normalize_key(key: &str) -> String {
82 if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
84 key.to_ascii_uppercase()
85 } else {
86 key.replace('_', "-").to_ascii_lowercase()
87 }
88}
89
90#[derive(Debug, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
92pub struct RuleConfig {
93 #[serde(flatten)]
95 #[schemars(schema_with = "arbitrary_value_schema")]
96 pub values: BTreeMap<String, toml::Value>,
97}
98
99fn arbitrary_value_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
101 use schemars::schema::*;
102 Schema::Object(SchemaObject {
103 instance_type: Some(InstanceType::Object.into()),
104 object: Some(Box::new(ObjectValidation {
105 additional_properties: Some(Box::new(Schema::Bool(true))),
106 ..Default::default()
107 })),
108 ..Default::default()
109 })
110}
111
112#[derive(Debug, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
114#[schemars(
115 description = "rumdl configuration for linting Markdown files. Rules can be configured individually using [MD###] sections with rule-specific options."
116)]
117pub struct Config {
118 #[serde(default)]
120 pub global: GlobalConfig,
121
122 #[serde(default, rename = "per-file-ignores")]
125 pub per_file_ignores: HashMap<String, Vec<String>>,
126
127 #[serde(flatten)]
138 pub rules: BTreeMap<String, RuleConfig>,
139}
140
141impl Config {
142 pub fn is_mkdocs_flavor(&self) -> bool {
144 self.global.flavor == MarkdownFlavor::MkDocs
145 }
146
147 pub fn markdown_flavor(&self) -> MarkdownFlavor {
153 self.global.flavor
154 }
155
156 pub fn is_mkdocs_project(&self) -> bool {
158 self.is_mkdocs_flavor()
159 }
160
161 pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
164 use globset::{Glob, GlobSetBuilder};
165
166 let mut ignored_rules = HashSet::new();
167
168 if self.per_file_ignores.is_empty() {
169 return ignored_rules;
170 }
171
172 let mut builder = GlobSetBuilder::new();
174 let mut pattern_to_rules: Vec<(usize, &Vec<String>)> = Vec::new();
175
176 for (idx, (pattern, rules)) in self.per_file_ignores.iter().enumerate() {
177 if let Ok(glob) = Glob::new(pattern) {
178 builder.add(glob);
179 pattern_to_rules.push((idx, rules));
180 } else {
181 log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
182 }
183 }
184
185 let globset = match builder.build() {
186 Ok(gs) => gs,
187 Err(e) => {
188 log::error!("Failed to build globset for per-file-ignores: {e}");
189 return ignored_rules;
190 }
191 };
192
193 for match_idx in globset.matches(file_path) {
195 if let Some((_, rules)) = pattern_to_rules.get(match_idx) {
196 for rule in rules.iter() {
197 ignored_rules.insert(normalize_key(rule));
199 }
200 }
201 }
202
203 ignored_rules
204 }
205}
206
207#[derive(Debug, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
209#[serde(default)]
210pub struct GlobalConfig {
211 #[serde(default)]
213 pub enable: Vec<String>,
214
215 #[serde(default)]
217 pub disable: Vec<String>,
218
219 #[serde(default)]
221 pub exclude: Vec<String>,
222
223 #[serde(default)]
225 pub include: Vec<String>,
226
227 #[serde(default = "default_respect_gitignore")]
229 pub respect_gitignore: bool,
230
231 #[serde(default = "default_line_length")]
233 pub line_length: u64,
234
235 #[serde(skip_serializing_if = "Option::is_none")]
237 pub output_format: Option<String>,
238
239 #[serde(default)]
242 pub fixable: Vec<String>,
243
244 #[serde(default)]
247 pub unfixable: Vec<String>,
248
249 #[serde(default)]
252 pub flavor: MarkdownFlavor,
253
254 #[serde(default)]
259 #[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
260 pub force_exclude: bool,
261}
262
263fn default_respect_gitignore() -> bool {
264 true
265}
266
267fn default_line_length() -> u64 {
268 80
269}
270
271impl Default for GlobalConfig {
273 #[allow(deprecated)]
274 fn default() -> Self {
275 Self {
276 enable: Vec::new(),
277 disable: Vec::new(),
278 exclude: Vec::new(),
279 include: Vec::new(),
280 respect_gitignore: true,
281 line_length: 80,
282 output_format: None,
283 fixable: Vec::new(),
284 unfixable: Vec::new(),
285 flavor: MarkdownFlavor::default(),
286 force_exclude: false,
287 }
288 }
289}
290
291const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
292 ".markdownlint.json",
293 ".markdownlint.jsonc",
294 ".markdownlint.yaml",
295 ".markdownlint.yml",
296 "markdownlint.json",
297 "markdownlint.jsonc",
298 "markdownlint.yaml",
299 "markdownlint.yml",
300];
301
302pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
304 if Path::new(path).exists() {
306 return Err(ConfigError::FileExists { path: path.to_string() });
307 }
308
309 let default_config = r#"# rumdl configuration file
311
312# Global configuration options
313[global]
314# List of rules to disable (uncomment and modify as needed)
315# disable = ["MD013", "MD033"]
316
317# List of rules to enable exclusively (if provided, only these rules will run)
318# enable = ["MD001", "MD003", "MD004"]
319
320# List of file/directory patterns to include for linting (if provided, only these will be linted)
321# include = [
322# "docs/*.md",
323# "src/**/*.md",
324# "README.md"
325# ]
326
327# List of file/directory patterns to exclude from linting
328exclude = [
329 # Common directories to exclude
330 ".git",
331 ".github",
332 "node_modules",
333 "vendor",
334 "dist",
335 "build",
336
337 # Specific files or patterns
338 "CHANGELOG.md",
339 "LICENSE.md",
340]
341
342# Respect .gitignore files when scanning directories (default: true)
343respect_gitignore = true
344
345# Markdown flavor/dialect (uncomment to enable)
346# Options: mkdocs, gfm, commonmark
347# flavor = "mkdocs"
348
349# Rule-specific configurations (uncomment and modify as needed)
350
351# [MD003]
352# style = "atx" # Heading style (atx, atx_closed, setext)
353
354# [MD004]
355# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
356
357# [MD007]
358# indent = 4 # Unordered list indentation
359
360# [MD013]
361# line_length = 100 # Line length
362# code_blocks = false # Exclude code blocks from line length check
363# tables = false # Exclude tables from line length check
364# headings = true # Include headings in line length check
365
366# [MD044]
367# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
368# code_blocks_excluded = true # Exclude code blocks from proper name check
369"#;
370
371 match fs::write(path, default_config) {
373 Ok(_) => Ok(()),
374 Err(err) => Err(ConfigError::IoError {
375 source: err,
376 path: path.to_string(),
377 }),
378 }
379}
380
381#[derive(Debug, thiserror::Error)]
383pub enum ConfigError {
384 #[error("Failed to read config file at {path}: {source}")]
386 IoError { source: io::Error, path: String },
387
388 #[error("Failed to parse config: {0}")]
390 ParseError(String),
391
392 #[error("Configuration file already exists at {path}")]
394 FileExists { path: String },
395}
396
397pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
401 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_config = config.rules.get(&norm_rule_name)?;
404
405 let key_variants = [
407 key.to_string(), normalize_key(key), key.replace('-', "_"), key.replace('_', "-"), ];
412
413 for variant in &key_variants {
415 if let Some(value) = rule_config.values.get(variant)
416 && let Ok(result) = T::deserialize(value.clone())
417 {
418 return Some(result);
419 }
420 }
421
422 None
423}
424
425pub fn generate_pyproject_config() -> String {
427 let config_content = r#"
428[tool.rumdl]
429# Global configuration options
430line-length = 100
431disable = []
432exclude = [
433 # Common directories to exclude
434 ".git",
435 ".github",
436 "node_modules",
437 "vendor",
438 "dist",
439 "build",
440]
441respect-gitignore = true
442
443# Rule-specific configurations (uncomment and modify as needed)
444
445# [tool.rumdl.MD003]
446# style = "atx" # Heading style (atx, atx_closed, setext)
447
448# [tool.rumdl.MD004]
449# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
450
451# [tool.rumdl.MD007]
452# indent = 4 # Unordered list indentation
453
454# [tool.rumdl.MD013]
455# line_length = 100 # Line length
456# code_blocks = false # Exclude code blocks from line length check
457# tables = false # Exclude tables from line length check
458# headings = true # Include headings in line length check
459
460# [tool.rumdl.MD044]
461# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
462# code_blocks_excluded = true # Exclude code blocks from proper name check
463"#;
464
465 config_content.to_string()
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use std::fs;
472 use tempfile::tempdir;
473
474 #[test]
475 fn test_flavor_loading() {
476 let temp_dir = tempdir().unwrap();
477 let config_path = temp_dir.path().join(".rumdl.toml");
478 let config_content = r#"
479[global]
480flavor = "mkdocs"
481disable = ["MD001"]
482"#;
483 fs::write(&config_path, config_content).unwrap();
484
485 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
487 let config: Config = sourced.into();
488
489 assert_eq!(config.global.flavor, MarkdownFlavor::MkDocs);
491 assert!(config.is_mkdocs_flavor());
492 assert!(config.is_mkdocs_project()); assert_eq!(config.global.disable, vec!["MD001".to_string()]);
494 }
495
496 #[test]
497 fn test_pyproject_toml_root_level_config() {
498 let temp_dir = tempdir().unwrap();
499 let config_path = temp_dir.path().join("pyproject.toml");
500
501 let content = r#"
503[tool.rumdl]
504line-length = 120
505disable = ["MD033"]
506enable = ["MD001", "MD004"]
507include = ["docs/*.md"]
508exclude = ["node_modules"]
509respect-gitignore = true
510 "#;
511
512 fs::write(&config_path, content).unwrap();
513
514 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
516 let config: Config = sourced.into(); assert_eq!(config.global.disable, vec!["MD033".to_string()]);
520 assert_eq!(config.global.enable, vec!["MD001".to_string(), "MD004".to_string()]);
521 assert_eq!(config.global.include, vec!["docs/*.md".to_string()]);
523 assert_eq!(config.global.exclude, vec!["node_modules".to_string()]);
524 assert!(config.global.respect_gitignore);
525
526 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
528 assert_eq!(line_length, Some(120));
529 }
530
531 #[test]
532 fn test_pyproject_toml_snake_case_and_kebab_case() {
533 let temp_dir = tempdir().unwrap();
534 let config_path = temp_dir.path().join("pyproject.toml");
535
536 let content = r#"
538[tool.rumdl]
539line-length = 150
540respect_gitignore = true
541 "#;
542
543 fs::write(&config_path, content).unwrap();
544
545 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
547 let config: Config = sourced.into(); assert!(config.global.respect_gitignore);
551 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
552 assert_eq!(line_length, Some(150));
553 }
554
555 #[test]
556 fn test_md013_key_normalization_in_rumdl_toml() {
557 let temp_dir = tempdir().unwrap();
558 let config_path = temp_dir.path().join(".rumdl.toml");
559 let config_content = r#"
560[MD013]
561line_length = 111
562line-length = 222
563"#;
564 fs::write(&config_path, config_content).unwrap();
565 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
567 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
568 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
570 assert_eq!(keys, vec!["line-length"]);
571 let val = &rule_cfg.values["line-length"].value;
572 assert_eq!(val.as_integer(), Some(222));
573 let config: Config = sourced.clone().into();
575 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
576 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
577 assert_eq!(v1, Some(222));
578 assert_eq!(v2, Some(222));
579 }
580
581 #[test]
582 fn test_md013_section_case_insensitivity() {
583 let temp_dir = tempdir().unwrap();
584 let config_path = temp_dir.path().join(".rumdl.toml");
585 let config_content = r#"
586[md013]
587line-length = 101
588
589[Md013]
590line-length = 102
591
592[MD013]
593line-length = 103
594"#;
595 fs::write(&config_path, config_content).unwrap();
596 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
598 let config: Config = sourced.clone().into();
599 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
601 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
602 assert_eq!(keys, vec!["line-length"]);
603 let val = &rule_cfg.values["line-length"].value;
604 assert_eq!(val.as_integer(), Some(103));
605 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
606 assert_eq!(v, Some(103));
607 }
608
609 #[test]
610 fn test_md013_key_snake_and_kebab_case() {
611 let temp_dir = tempdir().unwrap();
612 let config_path = temp_dir.path().join(".rumdl.toml");
613 let config_content = r#"
614[MD013]
615line_length = 201
616line-length = 202
617"#;
618 fs::write(&config_path, config_content).unwrap();
619 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
621 let config: Config = sourced.clone().into();
622 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
623 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
624 assert_eq!(keys, vec!["line-length"]);
625 let val = &rule_cfg.values["line-length"].value;
626 assert_eq!(val.as_integer(), Some(202));
627 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
628 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
629 assert_eq!(v1, Some(202));
630 assert_eq!(v2, Some(202));
631 }
632
633 #[test]
634 fn test_unknown_rule_section_is_ignored() {
635 let temp_dir = tempdir().unwrap();
636 let config_path = temp_dir.path().join(".rumdl.toml");
637 let config_content = r#"
638[MD999]
639foo = 1
640bar = 2
641[MD013]
642line-length = 303
643"#;
644 fs::write(&config_path, config_content).unwrap();
645 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
647 let config: Config = sourced.clone().into();
648 assert!(!sourced.rules.contains_key("MD999"));
650 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
652 assert_eq!(v, Some(303));
653 }
654
655 #[test]
656 fn test_invalid_toml_syntax() {
657 let temp_dir = tempdir().unwrap();
658 let config_path = temp_dir.path().join(".rumdl.toml");
659
660 let config_content = r#"
662[MD013]
663line-length = "unclosed string
664"#;
665 fs::write(&config_path, config_content).unwrap();
666
667 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
668 assert!(result.is_err());
669 match result.unwrap_err() {
670 ConfigError::ParseError(msg) => {
671 assert!(msg.contains("expected") || msg.contains("invalid") || msg.contains("unterminated"));
673 }
674 _ => panic!("Expected ParseError"),
675 }
676 }
677
678 #[test]
679 fn test_wrong_type_for_config_value() {
680 let temp_dir = tempdir().unwrap();
681 let config_path = temp_dir.path().join(".rumdl.toml");
682
683 let config_content = r#"
685[MD013]
686line-length = "not a number"
687"#;
688 fs::write(&config_path, config_content).unwrap();
689
690 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
691 let config: Config = sourced.into();
692
693 let rule_config = config.rules.get("MD013").unwrap();
695 let value = rule_config.values.get("line-length").unwrap();
696 assert!(matches!(value, toml::Value::String(_)));
697 }
698
699 #[test]
700 fn test_empty_config_file() {
701 let temp_dir = tempdir().unwrap();
702 let config_path = temp_dir.path().join(".rumdl.toml");
703
704 fs::write(&config_path, "").unwrap();
706
707 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
708 let config: Config = sourced.into();
709
710 assert_eq!(config.global.line_length, 80);
712 assert!(config.global.respect_gitignore);
713 assert!(config.rules.is_empty());
714 }
715
716 #[test]
717 fn test_malformed_pyproject_toml() {
718 let temp_dir = tempdir().unwrap();
719 let config_path = temp_dir.path().join("pyproject.toml");
720
721 let content = r#"
723[tool.rumdl
724line-length = 120
725"#;
726 fs::write(&config_path, content).unwrap();
727
728 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
729 assert!(result.is_err());
730 }
731
732 #[test]
733 fn test_conflicting_config_values() {
734 let temp_dir = tempdir().unwrap();
735 let config_path = temp_dir.path().join(".rumdl.toml");
736
737 let config_content = r#"
739[global]
740enable = ["MD013"]
741disable = ["MD013"]
742"#;
743 fs::write(&config_path, config_content).unwrap();
744
745 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
746 let config: Config = sourced.into();
747
748 assert!(config.global.enable.contains(&"MD013".to_string()));
750 assert!(config.global.disable.contains(&"MD013".to_string()));
751 }
752
753 #[test]
754 fn test_invalid_rule_names() {
755 let temp_dir = tempdir().unwrap();
756 let config_path = temp_dir.path().join(".rumdl.toml");
757
758 let config_content = r#"
759[global]
760enable = ["MD001", "NOT_A_RULE", "md002", "12345"]
761disable = ["MD-001", "MD_002"]
762"#;
763 fs::write(&config_path, config_content).unwrap();
764
765 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
766 let config: Config = sourced.into();
767
768 assert_eq!(config.global.enable.len(), 4);
770 assert_eq!(config.global.disable.len(), 2);
771 }
772
773 #[test]
774 fn test_deeply_nested_config() {
775 let temp_dir = tempdir().unwrap();
776 let config_path = temp_dir.path().join(".rumdl.toml");
777
778 let config_content = r#"
780[MD013]
781line-length = 100
782[MD013.nested]
783value = 42
784"#;
785 fs::write(&config_path, config_content).unwrap();
786
787 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
788 let config: Config = sourced.into();
789
790 let rule_config = config.rules.get("MD013").unwrap();
791 assert_eq!(
792 rule_config.values.get("line-length").unwrap(),
793 &toml::Value::Integer(100)
794 );
795 assert!(!rule_config.values.contains_key("nested"));
797 }
798
799 #[test]
800 fn test_unicode_in_config() {
801 let temp_dir = tempdir().unwrap();
802 let config_path = temp_dir.path().join(".rumdl.toml");
803
804 let config_content = r#"
805[global]
806include = ["文档/*.md", "ドã‚ュメント/*.md"]
807exclude = ["测试/*", "🚀/*"]
808
809[MD013]
810line-length = 80
811message = "行太长了 🚨"
812"#;
813 fs::write(&config_path, config_content).unwrap();
814
815 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
816 let config: Config = sourced.into();
817
818 assert_eq!(config.global.include.len(), 2);
819 assert_eq!(config.global.exclude.len(), 2);
820 assert!(config.global.include[0].contains("文档"));
821 assert!(config.global.exclude[1].contains("🚀"));
822
823 let rule_config = config.rules.get("MD013").unwrap();
824 let message = rule_config.values.get("message").unwrap();
825 if let toml::Value::String(s) = message {
826 assert!(s.contains("行太长了"));
827 assert!(s.contains("🚨"));
828 }
829 }
830
831 #[test]
832 fn test_extremely_long_values() {
833 let temp_dir = tempdir().unwrap();
834 let config_path = temp_dir.path().join(".rumdl.toml");
835
836 let long_string = "a".repeat(10000);
837 let config_content = format!(
838 r#"
839[global]
840exclude = ["{long_string}"]
841
842[MD013]
843line-length = 999999999
844"#
845 );
846
847 fs::write(&config_path, config_content).unwrap();
848
849 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
850 let config: Config = sourced.into();
851
852 assert_eq!(config.global.exclude[0].len(), 10000);
853 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
854 assert_eq!(line_length, Some(999999999));
855 }
856
857 #[test]
858 fn test_config_with_comments() {
859 let temp_dir = tempdir().unwrap();
860 let config_path = temp_dir.path().join(".rumdl.toml");
861
862 let config_content = r#"
863[global]
864# This is a comment
865enable = ["MD001"] # Enable MD001
866# disable = ["MD002"] # This is commented out
867
868[MD013] # Line length rule
869line-length = 100 # Set to 100 characters
870# ignored = true # This setting is commented out
871"#;
872 fs::write(&config_path, config_content).unwrap();
873
874 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
875 let config: Config = sourced.into();
876
877 assert_eq!(config.global.enable, vec!["MD001"]);
878 assert!(config.global.disable.is_empty()); let rule_config = config.rules.get("MD013").unwrap();
881 assert_eq!(rule_config.values.len(), 1); assert!(!rule_config.values.contains_key("ignored"));
883 }
884
885 #[test]
886 fn test_arrays_in_rule_config() {
887 let temp_dir = tempdir().unwrap();
888 let config_path = temp_dir.path().join(".rumdl.toml");
889
890 let config_content = r#"
891[MD002]
892levels = [1, 2, 3]
893tags = ["important", "critical"]
894mixed = [1, "two", true]
895"#;
896 fs::write(&config_path, config_content).unwrap();
897
898 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
899 let config: Config = sourced.into();
900
901 let rule_config = config.rules.get("MD002").expect("MD002 config should exist");
903
904 assert!(rule_config.values.contains_key("levels"));
906 assert!(rule_config.values.contains_key("tags"));
907 assert!(rule_config.values.contains_key("mixed"));
908
909 if let Some(toml::Value::Array(levels)) = rule_config.values.get("levels") {
911 assert_eq!(levels.len(), 3);
912 assert_eq!(levels[0], toml::Value::Integer(1));
913 assert_eq!(levels[1], toml::Value::Integer(2));
914 assert_eq!(levels[2], toml::Value::Integer(3));
915 } else {
916 panic!("levels should be an array");
917 }
918
919 if let Some(toml::Value::Array(tags)) = rule_config.values.get("tags") {
920 assert_eq!(tags.len(), 2);
921 assert_eq!(tags[0], toml::Value::String("important".to_string()));
922 assert_eq!(tags[1], toml::Value::String("critical".to_string()));
923 } else {
924 panic!("tags should be an array");
925 }
926
927 if let Some(toml::Value::Array(mixed)) = rule_config.values.get("mixed") {
928 assert_eq!(mixed.len(), 3);
929 assert_eq!(mixed[0], toml::Value::Integer(1));
930 assert_eq!(mixed[1], toml::Value::String("two".to_string()));
931 assert_eq!(mixed[2], toml::Value::Boolean(true));
932 } else {
933 panic!("mixed should be an array");
934 }
935 }
936
937 #[test]
938 fn test_normalize_key_edge_cases() {
939 assert_eq!(normalize_key("MD001"), "MD001");
941 assert_eq!(normalize_key("md001"), "MD001");
942 assert_eq!(normalize_key("Md001"), "MD001");
943 assert_eq!(normalize_key("mD001"), "MD001");
944
945 assert_eq!(normalize_key("line_length"), "line-length");
947 assert_eq!(normalize_key("line-length"), "line-length");
948 assert_eq!(normalize_key("LINE_LENGTH"), "line-length");
949 assert_eq!(normalize_key("respect_gitignore"), "respect-gitignore");
950
951 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(""), "");
958 assert_eq!(normalize_key("_"), "-");
959 assert_eq!(normalize_key("___"), "---");
960 }
961
962 #[test]
963 fn test_missing_config_file() {
964 let temp_dir = tempdir().unwrap();
965 let config_path = temp_dir.path().join("nonexistent.toml");
966
967 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
968 assert!(result.is_err());
969 match result.unwrap_err() {
970 ConfigError::IoError { .. } => {}
971 _ => panic!("Expected IoError for missing file"),
972 }
973 }
974
975 #[test]
976 #[cfg(unix)]
977 fn test_permission_denied_config() {
978 use std::os::unix::fs::PermissionsExt;
979
980 let temp_dir = tempdir().unwrap();
981 let config_path = temp_dir.path().join(".rumdl.toml");
982
983 fs::write(&config_path, "enable = [\"MD001\"]").unwrap();
984
985 let mut perms = fs::metadata(&config_path).unwrap().permissions();
987 perms.set_mode(0o000);
988 fs::set_permissions(&config_path, perms).unwrap();
989
990 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
991
992 let mut perms = fs::metadata(&config_path).unwrap().permissions();
994 perms.set_mode(0o644);
995 fs::set_permissions(&config_path, perms).unwrap();
996
997 assert!(result.is_err());
998 match result.unwrap_err() {
999 ConfigError::IoError { .. } => {}
1000 _ => panic!("Expected IoError for permission denied"),
1001 }
1002 }
1003
1004 #[test]
1005 fn test_circular_reference_detection() {
1006 let temp_dir = tempdir().unwrap();
1009 let config_path = temp_dir.path().join(".rumdl.toml");
1010
1011 let mut config_content = String::from("[MD001]\n");
1012 for i in 0..100 {
1013 config_content.push_str(&format!("key{i} = {i}\n"));
1014 }
1015
1016 fs::write(&config_path, config_content).unwrap();
1017
1018 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1019 let config: Config = sourced.into();
1020
1021 let rule_config = config.rules.get("MD001").unwrap();
1022 assert_eq!(rule_config.values.len(), 100);
1023 }
1024
1025 #[test]
1026 fn test_special_toml_values() {
1027 let temp_dir = tempdir().unwrap();
1028 let config_path = temp_dir.path().join(".rumdl.toml");
1029
1030 let config_content = r#"
1031[MD001]
1032infinity = inf
1033neg_infinity = -inf
1034not_a_number = nan
1035datetime = 1979-05-27T07:32:00Z
1036local_date = 1979-05-27
1037local_time = 07:32:00
1038"#;
1039 fs::write(&config_path, config_content).unwrap();
1040
1041 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1042 let config: Config = sourced.into();
1043
1044 if let Some(rule_config) = config.rules.get("MD001") {
1046 if let Some(toml::Value::Float(f)) = rule_config.values.get("infinity") {
1048 assert!(f.is_infinite() && f.is_sign_positive());
1049 }
1050 if let Some(toml::Value::Float(f)) = rule_config.values.get("neg_infinity") {
1051 assert!(f.is_infinite() && f.is_sign_negative());
1052 }
1053 if let Some(toml::Value::Float(f)) = rule_config.values.get("not_a_number") {
1054 assert!(f.is_nan());
1055 }
1056
1057 if let Some(val) = rule_config.values.get("datetime") {
1059 assert!(matches!(val, toml::Value::Datetime(_)));
1060 }
1061 }
1063 }
1064
1065 #[test]
1066 fn test_default_config_passes_validation() {
1067 use crate::rules;
1068
1069 let temp_dir = tempdir().unwrap();
1070 let config_path = temp_dir.path().join(".rumdl.toml");
1071 let config_path_str = config_path.to_str().unwrap();
1072
1073 create_default_config(config_path_str).unwrap();
1075
1076 let sourced =
1078 SourcedConfig::load(Some(config_path_str), None).expect("Default config should load successfully");
1079
1080 let all_rules = rules::all_rules(&Config::default());
1082 let registry = RuleRegistry::from_rules(&all_rules);
1083
1084 let warnings = validate_config_sourced(&sourced, ®istry);
1086
1087 if !warnings.is_empty() {
1089 for warning in &warnings {
1090 eprintln!("Config validation warning: {}", warning.message);
1091 if let Some(rule) = &warning.rule {
1092 eprintln!(" Rule: {rule}");
1093 }
1094 if let Some(key) = &warning.key {
1095 eprintln!(" Key: {key}");
1096 }
1097 }
1098 }
1099 assert!(
1100 warnings.is_empty(),
1101 "Default config from rumdl init should pass validation without warnings"
1102 );
1103 }
1104
1105 #[test]
1106 fn test_per_file_ignores_config_parsing() {
1107 let temp_dir = tempdir().unwrap();
1108 let config_path = temp_dir.path().join(".rumdl.toml");
1109 let config_content = r#"
1110[per-file-ignores]
1111"README.md" = ["MD033"]
1112"docs/**/*.md" = ["MD013", "MD033"]
1113"test/*.md" = ["MD041"]
1114"#;
1115 fs::write(&config_path, config_content).unwrap();
1116
1117 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1118 let config: Config = sourced.into();
1119
1120 assert_eq!(config.per_file_ignores.len(), 3);
1122 assert_eq!(
1123 config.per_file_ignores.get("README.md"),
1124 Some(&vec!["MD033".to_string()])
1125 );
1126 assert_eq!(
1127 config.per_file_ignores.get("docs/**/*.md"),
1128 Some(&vec!["MD013".to_string(), "MD033".to_string()])
1129 );
1130 assert_eq!(
1131 config.per_file_ignores.get("test/*.md"),
1132 Some(&vec!["MD041".to_string()])
1133 );
1134 }
1135
1136 #[test]
1137 fn test_per_file_ignores_glob_matching() {
1138 use std::path::PathBuf;
1139
1140 let temp_dir = tempdir().unwrap();
1141 let config_path = temp_dir.path().join(".rumdl.toml");
1142 let config_content = r#"
1143[per-file-ignores]
1144"README.md" = ["MD033"]
1145"docs/**/*.md" = ["MD013"]
1146"**/test_*.md" = ["MD041"]
1147"#;
1148 fs::write(&config_path, config_content).unwrap();
1149
1150 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1151 let config: Config = sourced.into();
1152
1153 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1155 assert!(ignored.contains("MD033"));
1156 assert_eq!(ignored.len(), 1);
1157
1158 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1160 assert!(ignored.contains("MD013"));
1161 assert_eq!(ignored.len(), 1);
1162
1163 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("tests/fixtures/test_example.md"));
1165 assert!(ignored.contains("MD041"));
1166 assert_eq!(ignored.len(), 1);
1167
1168 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("other/file.md"));
1170 assert!(ignored.is_empty());
1171 }
1172
1173 #[test]
1174 fn test_per_file_ignores_pyproject_toml() {
1175 let temp_dir = tempdir().unwrap();
1176 let config_path = temp_dir.path().join("pyproject.toml");
1177 let config_content = r#"
1178[tool.rumdl]
1179[tool.rumdl.per-file-ignores]
1180"README.md" = ["MD033", "MD013"]
1181"generated/*.md" = ["MD041"]
1182"#;
1183 fs::write(&config_path, config_content).unwrap();
1184
1185 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1186 let config: Config = sourced.into();
1187
1188 assert_eq!(config.per_file_ignores.len(), 2);
1190 assert_eq!(
1191 config.per_file_ignores.get("README.md"),
1192 Some(&vec!["MD033".to_string(), "MD013".to_string()])
1193 );
1194 assert_eq!(
1195 config.per_file_ignores.get("generated/*.md"),
1196 Some(&vec!["MD041".to_string()])
1197 );
1198 }
1199
1200 #[test]
1201 fn test_per_file_ignores_multiple_patterns_match() {
1202 use std::path::PathBuf;
1203
1204 let temp_dir = tempdir().unwrap();
1205 let config_path = temp_dir.path().join(".rumdl.toml");
1206 let config_content = r#"
1207[per-file-ignores]
1208"docs/**/*.md" = ["MD013"]
1209"**/api/*.md" = ["MD033"]
1210"docs/api/overview.md" = ["MD041"]
1211"#;
1212 fs::write(&config_path, config_content).unwrap();
1213
1214 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1215 let config: Config = sourced.into();
1216
1217 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1219 assert_eq!(ignored.len(), 3);
1220 assert!(ignored.contains("MD013"));
1221 assert!(ignored.contains("MD033"));
1222 assert!(ignored.contains("MD041"));
1223 }
1224
1225 #[test]
1226 fn test_per_file_ignores_rule_name_normalization() {
1227 use std::path::PathBuf;
1228
1229 let temp_dir = tempdir().unwrap();
1230 let config_path = temp_dir.path().join(".rumdl.toml");
1231 let config_content = r#"
1232[per-file-ignores]
1233"README.md" = ["md033", "MD013", "Md041"]
1234"#;
1235 fs::write(&config_path, config_content).unwrap();
1236
1237 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1238 let config: Config = sourced.into();
1239
1240 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1242 assert_eq!(ignored.len(), 3);
1243 assert!(ignored.contains("MD033"));
1244 assert!(ignored.contains("MD013"));
1245 assert!(ignored.contains("MD041"));
1246 }
1247
1248 #[test]
1249 fn test_per_file_ignores_invalid_glob_pattern() {
1250 use std::path::PathBuf;
1251
1252 let temp_dir = tempdir().unwrap();
1253 let config_path = temp_dir.path().join(".rumdl.toml");
1254 let config_content = r#"
1255[per-file-ignores]
1256"[invalid" = ["MD033"]
1257"valid/*.md" = ["MD013"]
1258"#;
1259 fs::write(&config_path, config_content).unwrap();
1260
1261 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1262 let config: Config = sourced.into();
1263
1264 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("valid/test.md"));
1266 assert!(ignored.contains("MD013"));
1267
1268 let ignored2 = config.get_ignored_rules_for_file(&PathBuf::from("[invalid"));
1270 assert!(ignored2.is_empty());
1271 }
1272
1273 #[test]
1274 fn test_per_file_ignores_empty_section() {
1275 use std::path::PathBuf;
1276
1277 let temp_dir = tempdir().unwrap();
1278 let config_path = temp_dir.path().join(".rumdl.toml");
1279 let config_content = r#"
1280[global]
1281disable = ["MD001"]
1282
1283[per-file-ignores]
1284"#;
1285 fs::write(&config_path, config_content).unwrap();
1286
1287 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1288 let config: Config = sourced.into();
1289
1290 assert_eq!(config.per_file_ignores.len(), 0);
1292 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1293 assert!(ignored.is_empty());
1294 }
1295
1296 #[test]
1297 fn test_per_file_ignores_with_underscores_in_pyproject() {
1298 let temp_dir = tempdir().unwrap();
1299 let config_path = temp_dir.path().join("pyproject.toml");
1300 let config_content = r#"
1301[tool.rumdl]
1302[tool.rumdl.per_file_ignores]
1303"README.md" = ["MD033"]
1304"#;
1305 fs::write(&config_path, config_content).unwrap();
1306
1307 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1308 let config: Config = sourced.into();
1309
1310 assert_eq!(config.per_file_ignores.len(), 1);
1312 assert_eq!(
1313 config.per_file_ignores.get("README.md"),
1314 Some(&vec!["MD033".to_string()])
1315 );
1316 }
1317
1318 #[test]
1319 fn test_generate_json_schema() {
1320 use schemars::schema_for;
1321 use std::env;
1322
1323 let schema = schema_for!(Config);
1324 let schema_json = serde_json::to_string_pretty(&schema).expect("Failed to serialize schema");
1325
1326 if env::var("RUMDL_UPDATE_SCHEMA").is_ok() {
1328 let schema_path = env::current_dir().unwrap().join("rumdl.schema.json");
1329 fs::write(&schema_path, &schema_json).expect("Failed to write schema file");
1330 println!("Schema written to: {}", schema_path.display());
1331 }
1332
1333 assert!(schema_json.contains("\"title\": \"Config\""));
1335 assert!(schema_json.contains("\"global\""));
1336 assert!(schema_json.contains("\"per-file-ignores\""));
1337 }
1338}
1339
1340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1341pub enum ConfigSource {
1342 Default,
1343 RumdlToml,
1344 PyprojectToml,
1345 Cli,
1346 Markdownlint,
1348}
1349
1350#[derive(Debug, Clone)]
1351pub struct ConfigOverride<T> {
1352 pub value: T,
1353 pub source: ConfigSource,
1354 pub file: Option<String>,
1355 pub line: Option<usize>,
1356}
1357
1358#[derive(Debug, Clone)]
1359pub struct SourcedValue<T> {
1360 pub value: T,
1361 pub source: ConfigSource,
1362 pub overrides: Vec<ConfigOverride<T>>,
1363}
1364
1365impl<T: Clone> SourcedValue<T> {
1366 pub fn new(value: T, source: ConfigSource) -> Self {
1367 Self {
1368 value: value.clone(),
1369 source,
1370 overrides: vec![ConfigOverride {
1371 value,
1372 source,
1373 file: None,
1374 line: None,
1375 }],
1376 }
1377 }
1378
1379 pub fn merge_override(
1383 &mut self,
1384 new_value: T,
1385 new_source: ConfigSource,
1386 new_file: Option<String>,
1387 new_line: Option<usize>,
1388 ) {
1389 fn source_precedence(src: ConfigSource) -> u8 {
1391 match src {
1392 ConfigSource::Default => 0,
1393 ConfigSource::PyprojectToml => 1,
1394 ConfigSource::Markdownlint => 2,
1395 ConfigSource::RumdlToml => 3,
1396 ConfigSource::Cli => 4,
1397 }
1398 }
1399
1400 if source_precedence(new_source) >= source_precedence(self.source) {
1401 self.value = new_value.clone();
1402 self.source = new_source;
1403 self.overrides.push(ConfigOverride {
1404 value: new_value,
1405 source: new_source,
1406 file: new_file,
1407 line: new_line,
1408 });
1409 }
1410 }
1411
1412 pub fn push_override(&mut self, value: T, source: ConfigSource, file: Option<String>, line: Option<usize>) {
1413 self.value = value.clone();
1416 self.source = source;
1417 self.overrides.push(ConfigOverride {
1418 value,
1419 source,
1420 file,
1421 line,
1422 });
1423 }
1424}
1425
1426#[derive(Debug, Clone)]
1427pub struct SourcedGlobalConfig {
1428 pub enable: SourcedValue<Vec<String>>,
1429 pub disable: SourcedValue<Vec<String>>,
1430 pub exclude: SourcedValue<Vec<String>>,
1431 pub include: SourcedValue<Vec<String>>,
1432 pub respect_gitignore: SourcedValue<bool>,
1433 pub line_length: SourcedValue<u64>,
1434 pub output_format: Option<SourcedValue<String>>,
1435 pub fixable: SourcedValue<Vec<String>>,
1436 pub unfixable: SourcedValue<Vec<String>>,
1437 pub flavor: SourcedValue<MarkdownFlavor>,
1438 pub force_exclude: SourcedValue<bool>,
1439}
1440
1441impl Default for SourcedGlobalConfig {
1442 fn default() -> Self {
1443 SourcedGlobalConfig {
1444 enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1445 disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1446 exclude: SourcedValue::new(Vec::new(), ConfigSource::Default),
1447 include: SourcedValue::new(Vec::new(), ConfigSource::Default),
1448 respect_gitignore: SourcedValue::new(true, ConfigSource::Default),
1449 line_length: SourcedValue::new(80, ConfigSource::Default),
1450 output_format: None,
1451 fixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1452 unfixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1453 flavor: SourcedValue::new(MarkdownFlavor::default(), ConfigSource::Default),
1454 force_exclude: SourcedValue::new(false, ConfigSource::Default),
1455 }
1456 }
1457}
1458
1459#[derive(Debug, Default, Clone)]
1460pub struct SourcedRuleConfig {
1461 pub values: BTreeMap<String, SourcedValue<toml::Value>>,
1462}
1463
1464#[derive(Debug, Clone)]
1467pub struct SourcedConfigFragment {
1468 pub global: SourcedGlobalConfig,
1469 pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
1470 pub rules: BTreeMap<String, SourcedRuleConfig>,
1471 }
1473
1474impl Default for SourcedConfigFragment {
1475 fn default() -> Self {
1476 Self {
1477 global: SourcedGlobalConfig::default(),
1478 per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
1479 rules: BTreeMap::new(),
1480 }
1481 }
1482}
1483
1484#[derive(Debug, Clone)]
1485pub struct SourcedConfig {
1486 pub global: SourcedGlobalConfig,
1487 pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
1488 pub rules: BTreeMap<String, SourcedRuleConfig>,
1489 pub loaded_files: Vec<String>,
1490 pub unknown_keys: Vec<(String, String)>, }
1492
1493impl Default for SourcedConfig {
1494 fn default() -> Self {
1495 Self {
1496 global: SourcedGlobalConfig::default(),
1497 per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
1498 rules: BTreeMap::new(),
1499 loaded_files: Vec::new(),
1500 unknown_keys: Vec::new(),
1501 }
1502 }
1503}
1504
1505impl SourcedConfig {
1506 fn merge(&mut self, fragment: SourcedConfigFragment) {
1509 self.global.enable.merge_override(
1511 fragment.global.enable.value,
1512 fragment.global.enable.source,
1513 fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
1514 fragment.global.enable.overrides.first().and_then(|o| o.line),
1515 );
1516 self.global.disable.merge_override(
1517 fragment.global.disable.value,
1518 fragment.global.disable.source,
1519 fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
1520 fragment.global.disable.overrides.first().and_then(|o| o.line),
1521 );
1522 self.global.include.merge_override(
1523 fragment.global.include.value,
1524 fragment.global.include.source,
1525 fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
1526 fragment.global.include.overrides.first().and_then(|o| o.line),
1527 );
1528 self.global.exclude.merge_override(
1529 fragment.global.exclude.value,
1530 fragment.global.exclude.source,
1531 fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
1532 fragment.global.exclude.overrides.first().and_then(|o| o.line),
1533 );
1534 self.global.respect_gitignore.merge_override(
1535 fragment.global.respect_gitignore.value,
1536 fragment.global.respect_gitignore.source,
1537 fragment
1538 .global
1539 .respect_gitignore
1540 .overrides
1541 .first()
1542 .and_then(|o| o.file.clone()),
1543 fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
1544 );
1545 self.global.line_length.merge_override(
1546 fragment.global.line_length.value,
1547 fragment.global.line_length.source,
1548 fragment
1549 .global
1550 .line_length
1551 .overrides
1552 .first()
1553 .and_then(|o| o.file.clone()),
1554 fragment.global.line_length.overrides.first().and_then(|o| o.line),
1555 );
1556 self.global.fixable.merge_override(
1557 fragment.global.fixable.value,
1558 fragment.global.fixable.source,
1559 fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
1560 fragment.global.fixable.overrides.first().and_then(|o| o.line),
1561 );
1562 self.global.unfixable.merge_override(
1563 fragment.global.unfixable.value,
1564 fragment.global.unfixable.source,
1565 fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
1566 fragment.global.unfixable.overrides.first().and_then(|o| o.line),
1567 );
1568
1569 self.global.flavor.merge_override(
1571 fragment.global.flavor.value,
1572 fragment.global.flavor.source,
1573 fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
1574 fragment.global.flavor.overrides.first().and_then(|o| o.line),
1575 );
1576
1577 self.global.force_exclude.merge_override(
1579 fragment.global.force_exclude.value,
1580 fragment.global.force_exclude.source,
1581 fragment
1582 .global
1583 .force_exclude
1584 .overrides
1585 .first()
1586 .and_then(|o| o.file.clone()),
1587 fragment.global.force_exclude.overrides.first().and_then(|o| o.line),
1588 );
1589
1590 if let Some(output_format_fragment) = fragment.global.output_format {
1592 if let Some(ref mut output_format) = self.global.output_format {
1593 output_format.merge_override(
1594 output_format_fragment.value,
1595 output_format_fragment.source,
1596 output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
1597 output_format_fragment.overrides.first().and_then(|o| o.line),
1598 );
1599 } else {
1600 self.global.output_format = Some(output_format_fragment);
1601 }
1602 }
1603
1604 self.per_file_ignores.merge_override(
1606 fragment.per_file_ignores.value,
1607 fragment.per_file_ignores.source,
1608 fragment.per_file_ignores.overrides.first().and_then(|o| o.file.clone()),
1609 fragment.per_file_ignores.overrides.first().and_then(|o| o.line),
1610 );
1611
1612 for (rule_name, rule_fragment) in fragment.rules {
1614 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
1616 for (key, sourced_value_fragment) in rule_fragment.values {
1617 let sv_entry = rule_entry
1618 .values
1619 .entry(key.clone())
1620 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
1621 let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
1622 let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
1623 sv_entry.merge_override(
1624 sourced_value_fragment.value, sourced_value_fragment.source, file_from_fragment, line_from_fragment, );
1629 }
1630 }
1631 }
1632
1633 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
1635 Self::load_with_discovery(config_path, cli_overrides, false)
1636 }
1637
1638 fn discover_config_upward() -> Option<std::path::PathBuf> {
1641 use std::env;
1642
1643 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
1644 const MAX_DEPTH: usize = 100; let start_dir = match env::current_dir() {
1647 Ok(dir) => dir,
1648 Err(e) => {
1649 log::debug!("[rumdl-config] Failed to get current directory: {e}");
1650 return None;
1651 }
1652 };
1653
1654 let mut current_dir = start_dir.clone();
1655 let mut depth = 0;
1656
1657 loop {
1658 if depth >= MAX_DEPTH {
1659 log::debug!("[rumdl-config] Maximum traversal depth reached");
1660 break;
1661 }
1662
1663 log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
1664
1665 for config_name in CONFIG_FILES {
1667 let config_path = current_dir.join(config_name);
1668
1669 if config_path.exists() {
1670 if *config_name == "pyproject.toml" {
1672 if let Ok(content) = std::fs::read_to_string(&config_path) {
1673 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
1674 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
1675 return Some(config_path);
1676 }
1677 log::debug!("[rumdl-config] Found pyproject.toml but no [tool.rumdl] section");
1678 continue;
1679 }
1680 } else {
1681 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
1682 return Some(config_path);
1683 }
1684 }
1685 }
1686
1687 if current_dir.join(".git").exists() {
1689 log::debug!("[rumdl-config] Stopping at .git directory");
1690 break;
1691 }
1692
1693 match current_dir.parent() {
1695 Some(parent) => {
1696 current_dir = parent.to_owned();
1697 depth += 1;
1698 }
1699 None => {
1700 log::debug!("[rumdl-config] Reached filesystem root");
1701 break;
1702 }
1703 }
1704 }
1705
1706 None
1707 }
1708
1709 fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
1711 let config_dir = config_dir.join("rumdl");
1712
1713 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
1715
1716 log::debug!(
1717 "[rumdl-config] Checking for user configuration in: {}",
1718 config_dir.display()
1719 );
1720
1721 for filename in USER_CONFIG_FILES {
1722 let config_path = config_dir.join(filename);
1723
1724 if config_path.exists() {
1725 if *filename == "pyproject.toml" {
1727 if let Ok(content) = std::fs::read_to_string(&config_path) {
1728 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
1729 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
1730 return Some(config_path);
1731 }
1732 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
1733 continue;
1734 }
1735 } else {
1736 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
1737 return Some(config_path);
1738 }
1739 }
1740 }
1741
1742 log::debug!(
1743 "[rumdl-config] No user configuration found in: {}",
1744 config_dir.display()
1745 );
1746 None
1747 }
1748
1749 fn user_configuration_path() -> Option<std::path::PathBuf> {
1752 use etcetera::{BaseStrategy, choose_base_strategy};
1753
1754 match choose_base_strategy() {
1755 Ok(strategy) => {
1756 let config_dir = strategy.config_dir();
1757 Self::user_configuration_path_impl(&config_dir)
1758 }
1759 Err(e) => {
1760 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
1761 None
1762 }
1763 }
1764 }
1765
1766 #[doc(hidden)]
1768 pub fn load_with_discovery_impl(
1769 config_path: Option<&str>,
1770 cli_overrides: Option<&SourcedGlobalConfig>,
1771 skip_auto_discovery: bool,
1772 user_config_dir: Option<&Path>,
1773 ) -> Result<Self, ConfigError> {
1774 use std::env;
1775 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
1776 if config_path.is_none() {
1777 if skip_auto_discovery {
1778 log::debug!("[rumdl-config] Skipping auto-discovery due to --no-config flag");
1779 } else {
1780 log::debug!("[rumdl-config] No explicit config_path provided, will search default locations");
1781 }
1782 } else {
1783 log::debug!("[rumdl-config] Explicit config_path provided: {config_path:?}");
1784 }
1785 let mut sourced_config = SourcedConfig::default();
1786
1787 if let Some(path) = config_path {
1789 let path_obj = Path::new(path);
1790 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
1791 log::debug!("[rumdl-config] Trying to load config file: {filename}");
1792 let path_str = path.to_string();
1793
1794 const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
1796
1797 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
1798 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
1799 source: e,
1800 path: path_str.clone(),
1801 })?;
1802 if filename == "pyproject.toml" {
1803 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
1804 sourced_config.merge(fragment);
1805 sourced_config.loaded_files.push(path_str.clone());
1806 }
1807 } else {
1808 let fragment = parse_rumdl_toml(&content, &path_str)?;
1809 sourced_config.merge(fragment);
1810 sourced_config.loaded_files.push(path_str.clone());
1811 }
1812 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
1813 || path_str.ends_with(".json")
1814 || path_str.ends_with(".jsonc")
1815 || path_str.ends_with(".yaml")
1816 || path_str.ends_with(".yml")
1817 {
1818 let fragment = load_from_markdownlint(&path_str)?;
1820 sourced_config.merge(fragment);
1821 sourced_config.loaded_files.push(path_str.clone());
1822 } else {
1824 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
1826 source: e,
1827 path: path_str.clone(),
1828 })?;
1829 let fragment = parse_rumdl_toml(&content, &path_str)?;
1830 sourced_config.merge(fragment);
1831 sourced_config.loaded_files.push(path_str.clone());
1832 }
1833 }
1834
1835 if !skip_auto_discovery && config_path.is_none() {
1837 let user_config_path = if let Some(dir) = user_config_dir {
1839 Self::user_configuration_path_impl(dir)
1840 } else {
1841 Self::user_configuration_path()
1842 };
1843
1844 if let Some(user_config_path) = user_config_path {
1845 let path_str = user_config_path.display().to_string();
1846 let filename = user_config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1847
1848 log::debug!("[rumdl-config] Loading user configuration file: {path_str}");
1849
1850 if filename == "pyproject.toml" {
1851 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
1852 source: e,
1853 path: path_str.clone(),
1854 })?;
1855 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
1856 sourced_config.merge(fragment);
1857 sourced_config.loaded_files.push(path_str);
1858 }
1859 } else {
1860 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
1861 source: e,
1862 path: path_str.clone(),
1863 })?;
1864 let fragment = parse_rumdl_toml(&content, &path_str)?;
1865 sourced_config.merge(fragment);
1866 sourced_config.loaded_files.push(path_str);
1867 }
1868 } else {
1869 log::debug!("[rumdl-config] No user configuration file found");
1870 }
1871
1872 if let Some(config_file) = Self::discover_config_upward() {
1874 let path_str = config_file.display().to_string();
1875 let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
1876
1877 log::debug!("[rumdl-config] Loading discovered config file: {path_str}");
1878
1879 if filename == "pyproject.toml" {
1880 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
1881 source: e,
1882 path: path_str.clone(),
1883 })?;
1884 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
1885 sourced_config.merge(fragment);
1886 sourced_config.loaded_files.push(path_str);
1887 }
1888 } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
1889 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
1890 source: e,
1891 path: path_str.clone(),
1892 })?;
1893 let fragment = parse_rumdl_toml(&content, &path_str)?;
1894 sourced_config.merge(fragment);
1895 sourced_config.loaded_files.push(path_str);
1896 }
1897 } else {
1898 log::debug!("[rumdl-config] No configuration file found via upward traversal");
1899
1900 let mut found_markdownlint = false;
1902 for filename in MARKDOWNLINT_CONFIG_FILES {
1903 if std::path::Path::new(filename).exists() {
1904 match load_from_markdownlint(filename) {
1905 Ok(fragment) => {
1906 sourced_config.merge(fragment);
1907 sourced_config.loaded_files.push(filename.to_string());
1908 found_markdownlint = true;
1909 break; }
1911 Err(_e) => {
1912 }
1914 }
1915 }
1916 }
1917
1918 if !found_markdownlint {
1919 log::debug!("[rumdl-config] No markdownlint configuration file found");
1920 }
1921 }
1922 }
1923
1924 if let Some(cli) = cli_overrides {
1926 sourced_config
1927 .global
1928 .enable
1929 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
1930 sourced_config
1931 .global
1932 .disable
1933 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
1934 sourced_config
1935 .global
1936 .exclude
1937 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
1938 sourced_config
1939 .global
1940 .include
1941 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
1942 sourced_config.global.respect_gitignore.merge_override(
1943 cli.respect_gitignore.value,
1944 ConfigSource::Cli,
1945 None,
1946 None,
1947 );
1948 sourced_config
1949 .global
1950 .fixable
1951 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
1952 sourced_config
1953 .global
1954 .unfixable
1955 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
1956 }
1958
1959 Ok(sourced_config)
1962 }
1963
1964 pub fn load_with_discovery(
1967 config_path: Option<&str>,
1968 cli_overrides: Option<&SourcedGlobalConfig>,
1969 skip_auto_discovery: bool,
1970 ) -> Result<Self, ConfigError> {
1971 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None)
1972 }
1973}
1974
1975impl From<SourcedConfig> for Config {
1976 fn from(sourced: SourcedConfig) -> Self {
1977 let mut rules = BTreeMap::new();
1978 for (rule_name, sourced_rule_cfg) in sourced.rules {
1979 let normalized_rule_name = rule_name.to_ascii_uppercase();
1981 let mut values = BTreeMap::new();
1982 for (key, sourced_val) in sourced_rule_cfg.values {
1983 values.insert(key, sourced_val.value);
1984 }
1985 rules.insert(normalized_rule_name, RuleConfig { values });
1986 }
1987 #[allow(deprecated)]
1988 let global = GlobalConfig {
1989 enable: sourced.global.enable.value,
1990 disable: sourced.global.disable.value,
1991 exclude: sourced.global.exclude.value,
1992 include: sourced.global.include.value,
1993 respect_gitignore: sourced.global.respect_gitignore.value,
1994 line_length: sourced.global.line_length.value,
1995 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
1996 fixable: sourced.global.fixable.value,
1997 unfixable: sourced.global.unfixable.value,
1998 flavor: sourced.global.flavor.value,
1999 force_exclude: sourced.global.force_exclude.value,
2000 };
2001 Config {
2002 global,
2003 per_file_ignores: sourced.per_file_ignores.value,
2004 rules,
2005 }
2006 }
2007}
2008
2009pub struct RuleRegistry {
2011 pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
2013 pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
2015}
2016
2017impl RuleRegistry {
2018 pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
2020 let mut rule_schemas = std::collections::BTreeMap::new();
2021 let mut rule_aliases = std::collections::BTreeMap::new();
2022
2023 for rule in rules {
2024 let norm_name = if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
2025 let norm_name = normalize_key(&name); rule_schemas.insert(norm_name.clone(), table);
2027 norm_name
2028 } else {
2029 let norm_name = normalize_key(rule.name()); rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
2031 norm_name
2032 };
2033
2034 if let Some(aliases) = rule.config_aliases() {
2036 rule_aliases.insert(norm_name, aliases);
2037 }
2038 }
2039
2040 RuleRegistry {
2041 rule_schemas,
2042 rule_aliases,
2043 }
2044 }
2045
2046 pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
2048 self.rule_schemas.keys().cloned().collect()
2049 }
2050
2051 pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
2053 self.rule_schemas.get(rule).map(|schema| {
2054 let mut all_keys = std::collections::BTreeSet::new();
2055
2056 for key in schema.keys() {
2058 all_keys.insert(key.clone());
2059 }
2060
2061 for key in schema.keys() {
2063 all_keys.insert(key.replace('_', "-"));
2065 all_keys.insert(key.replace('-', "_"));
2067 all_keys.insert(normalize_key(key));
2069 }
2070
2071 if let Some(aliases) = self.rule_aliases.get(rule) {
2073 for alias_key in aliases.keys() {
2074 all_keys.insert(alias_key.clone());
2075 all_keys.insert(alias_key.replace('_', "-"));
2077 all_keys.insert(alias_key.replace('-', "_"));
2078 all_keys.insert(normalize_key(alias_key));
2079 }
2080 }
2081
2082 all_keys
2083 })
2084 }
2085
2086 pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
2088 if let Some(schema) = self.rule_schemas.get(rule) {
2089 if let Some(aliases) = self.rule_aliases.get(rule)
2091 && let Some(canonical_key) = aliases.get(key)
2092 {
2093 if let Some(value) = schema.get(canonical_key) {
2095 return Some(value);
2096 }
2097 }
2098
2099 if let Some(value) = schema.get(key) {
2101 return Some(value);
2102 }
2103
2104 let key_variants = [
2106 key.replace('-', "_"), key.replace('_', "-"), normalize_key(key), ];
2110
2111 for variant in &key_variants {
2112 if let Some(value) = schema.get(variant) {
2113 return Some(value);
2114 }
2115 }
2116 }
2117 None
2118 }
2119}
2120
2121#[derive(Debug, Clone)]
2123pub struct ConfigValidationWarning {
2124 pub message: String,
2125 pub rule: Option<String>,
2126 pub key: Option<String>,
2127}
2128
2129pub fn validate_config_sourced(sourced: &SourcedConfig, registry: &RuleRegistry) -> Vec<ConfigValidationWarning> {
2131 let mut warnings = Vec::new();
2132 let known_rules = registry.rule_names();
2133 for rule in sourced.rules.keys() {
2135 if !known_rules.contains(rule) {
2136 warnings.push(ConfigValidationWarning {
2137 message: format!("Unknown rule in config: {rule}"),
2138 rule: Some(rule.clone()),
2139 key: None,
2140 });
2141 }
2142 }
2143 for (rule, rule_cfg) in &sourced.rules {
2145 if let Some(valid_keys) = registry.config_keys_for(rule) {
2146 for key in rule_cfg.values.keys() {
2147 if !valid_keys.contains(key) {
2148 warnings.push(ConfigValidationWarning {
2149 message: format!("Unknown option for rule {rule}: {key}"),
2150 rule: Some(rule.clone()),
2151 key: Some(key.clone()),
2152 });
2153 } else {
2154 if let Some(expected) = registry.expected_value_for(rule, key) {
2156 let actual = &rule_cfg.values[key].value;
2157 if !toml_value_type_matches(expected, actual) {
2158 warnings.push(ConfigValidationWarning {
2159 message: format!(
2160 "Type mismatch for {}.{}: expected {}, got {}",
2161 rule,
2162 key,
2163 toml_type_name(expected),
2164 toml_type_name(actual)
2165 ),
2166 rule: Some(rule.clone()),
2167 key: Some(key.clone()),
2168 });
2169 }
2170 }
2171 }
2172 }
2173 }
2174 }
2175 for (section, key) in &sourced.unknown_keys {
2177 if section.contains("[global]") {
2178 warnings.push(ConfigValidationWarning {
2179 message: format!("Unknown global option: {key}"),
2180 rule: None,
2181 key: Some(key.clone()),
2182 });
2183 }
2184 }
2185 warnings
2186}
2187
2188fn toml_type_name(val: &toml::Value) -> &'static str {
2189 match val {
2190 toml::Value::String(_) => "string",
2191 toml::Value::Integer(_) => "integer",
2192 toml::Value::Float(_) => "float",
2193 toml::Value::Boolean(_) => "boolean",
2194 toml::Value::Array(_) => "array",
2195 toml::Value::Table(_) => "table",
2196 toml::Value::Datetime(_) => "datetime",
2197 }
2198}
2199
2200fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
2201 use toml::Value::*;
2202 match (expected, actual) {
2203 (String(_), String(_)) => true,
2204 (Integer(_), Integer(_)) => true,
2205 (Float(_), Float(_)) => true,
2206 (Boolean(_), Boolean(_)) => true,
2207 (Array(_), Array(_)) => true,
2208 (Table(_), Table(_)) => true,
2209 (Datetime(_), Datetime(_)) => true,
2210 (Float(_), Integer(_)) => true,
2212 _ => false,
2213 }
2214}
2215
2216fn parse_pyproject_toml(content: &str, path: &str) -> Result<Option<SourcedConfigFragment>, ConfigError> {
2218 let doc: toml::Value =
2219 toml::from_str(content).map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
2220 let mut fragment = SourcedConfigFragment::default();
2221 let source = ConfigSource::PyprojectToml;
2222 let file = Some(path.to_string());
2223
2224 if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
2226 && let Some(rumdl_table) = rumdl_config.as_table()
2227 {
2228 let extract_global_config = |fragment: &mut SourcedConfigFragment, table: &toml::value::Table| {
2230 if let Some(enable) = table.get("enable")
2232 && let Ok(values) = Vec::<String>::deserialize(enable.clone())
2233 {
2234 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
2236 fragment
2237 .global
2238 .enable
2239 .push_override(normalized_values, source, file.clone(), None);
2240 }
2241
2242 if let Some(disable) = table.get("disable")
2243 && let Ok(values) = Vec::<String>::deserialize(disable.clone())
2244 {
2245 let normalized_values: Vec<String> = values.into_iter().map(|s| normalize_key(&s)).collect();
2247 fragment
2248 .global
2249 .disable
2250 .push_override(normalized_values, source, file.clone(), None);
2251 }
2252
2253 if let Some(include) = table.get("include")
2254 && let Ok(values) = Vec::<String>::deserialize(include.clone())
2255 {
2256 fragment
2257 .global
2258 .include
2259 .push_override(values, source, file.clone(), None);
2260 }
2261
2262 if let Some(exclude) = table.get("exclude")
2263 && let Ok(values) = Vec::<String>::deserialize(exclude.clone())
2264 {
2265 fragment
2266 .global
2267 .exclude
2268 .push_override(values, source, file.clone(), None);
2269 }
2270
2271 if let Some(respect_gitignore) = table
2272 .get("respect-gitignore")
2273 .or_else(|| table.get("respect_gitignore"))
2274 && let Ok(value) = bool::deserialize(respect_gitignore.clone())
2275 {
2276 fragment
2277 .global
2278 .respect_gitignore
2279 .push_override(value, source, file.clone(), None);
2280 }
2281
2282 if let Some(force_exclude) = table.get("force-exclude").or_else(|| table.get("force_exclude"))
2283 && let Ok(value) = bool::deserialize(force_exclude.clone())
2284 {
2285 fragment
2286 .global
2287 .force_exclude
2288 .push_override(value, source, file.clone(), None);
2289 }
2290
2291 if let Some(output_format) = table.get("output-format").or_else(|| table.get("output_format"))
2292 && let Ok(value) = String::deserialize(output_format.clone())
2293 {
2294 if fragment.global.output_format.is_none() {
2295 fragment.global.output_format = Some(SourcedValue::new(value.clone(), source));
2296 } else {
2297 fragment
2298 .global
2299 .output_format
2300 .as_mut()
2301 .unwrap()
2302 .push_override(value, source, file.clone(), None);
2303 }
2304 }
2305
2306 if let Some(fixable) = table.get("fixable")
2307 && let Ok(values) = Vec::<String>::deserialize(fixable.clone())
2308 {
2309 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
2310 fragment
2311 .global
2312 .fixable
2313 .push_override(normalized_values, source, file.clone(), None);
2314 }
2315
2316 if let Some(unfixable) = table.get("unfixable")
2317 && let Ok(values) = Vec::<String>::deserialize(unfixable.clone())
2318 {
2319 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
2320 fragment
2321 .global
2322 .unfixable
2323 .push_override(normalized_values, source, file.clone(), None);
2324 }
2325
2326 if let Some(flavor) = table.get("flavor")
2327 && let Ok(value) = MarkdownFlavor::deserialize(flavor.clone())
2328 {
2329 fragment.global.flavor.push_override(value, source, file.clone(), None);
2330 }
2331
2332 if let Some(line_length) = table.get("line-length").or_else(|| table.get("line_length"))
2334 && let Ok(value) = u64::deserialize(line_length.clone())
2335 {
2336 fragment
2337 .global
2338 .line_length
2339 .push_override(value, source, file.clone(), None);
2340
2341 let norm_md013_key = normalize_key("MD013");
2343 let rule_entry = fragment.rules.entry(norm_md013_key).or_default();
2344 let norm_line_length_key = normalize_key("line-length");
2345 let sv = rule_entry
2346 .values
2347 .entry(norm_line_length_key)
2348 .or_insert_with(|| SourcedValue::new(line_length.clone(), ConfigSource::Default));
2349 sv.push_override(line_length.clone(), source, file.clone(), None);
2350 }
2351 };
2352
2353 if let Some(global_table) = rumdl_table.get("global").and_then(|g| g.as_table()) {
2355 extract_global_config(&mut fragment, global_table);
2356 }
2357
2358 extract_global_config(&mut fragment, rumdl_table);
2360
2361 let per_file_ignores_key = rumdl_table
2364 .get("per-file-ignores")
2365 .or_else(|| rumdl_table.get("per_file_ignores"));
2366
2367 if let Some(per_file_ignores_value) = per_file_ignores_key
2368 && let Some(per_file_table) = per_file_ignores_value.as_table()
2369 {
2370 let mut per_file_map = HashMap::new();
2371 for (pattern, rules_value) in per_file_table {
2372 if let Ok(rules) = Vec::<String>::deserialize(rules_value.clone()) {
2373 let normalized_rules = rules.into_iter().map(|s| normalize_key(&s)).collect();
2374 per_file_map.insert(pattern.clone(), normalized_rules);
2375 } else {
2376 log::warn!(
2377 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {rules_value:?}"
2378 );
2379 }
2380 }
2381 fragment
2382 .per_file_ignores
2383 .push_override(per_file_map, source, file.clone(), None);
2384 }
2385
2386 for (key, value) in rumdl_table {
2388 let norm_rule_key = normalize_key(key);
2389
2390 if [
2392 "enable",
2393 "disable",
2394 "include",
2395 "exclude",
2396 "respect_gitignore",
2397 "respect-gitignore", "force_exclude",
2399 "force-exclude",
2400 "line_length",
2401 "line-length",
2402 "output_format",
2403 "output-format",
2404 "fixable",
2405 "unfixable",
2406 "per-file-ignores",
2407 "per_file_ignores",
2408 "global",
2409 ]
2410 .contains(&norm_rule_key.as_str())
2411 {
2412 continue;
2413 }
2414
2415 let norm_rule_key_upper = norm_rule_key.to_ascii_uppercase();
2419 if norm_rule_key_upper.len() == 5
2420 && norm_rule_key_upper.starts_with("MD")
2421 && norm_rule_key_upper[2..].chars().all(|c| c.is_ascii_digit())
2422 && value.is_table()
2423 {
2424 if let Some(rule_config_table) = value.as_table() {
2425 let rule_entry = fragment.rules.entry(norm_rule_key_upper).or_default();
2427 for (rk, rv) in rule_config_table {
2428 let norm_rk = normalize_key(rk); let toml_val = rv.clone();
2431
2432 let sv = rule_entry
2433 .values
2434 .entry(norm_rk.clone())
2435 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
2436 sv.push_override(toml_val, source, file.clone(), None);
2437 }
2438 }
2439 } else {
2440 }
2444 }
2445 }
2446
2447 if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
2449 for (key, value) in tool_table.iter() {
2450 if let Some(rule_name) = key.strip_prefix("rumdl.") {
2451 let norm_rule_name = normalize_key(rule_name);
2452 if norm_rule_name.len() == 5
2453 && norm_rule_name.to_ascii_uppercase().starts_with("MD")
2454 && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
2455 && let Some(rule_table) = value.as_table()
2456 {
2457 let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
2458 for (rk, rv) in rule_table {
2459 let norm_rk = normalize_key(rk);
2460 let toml_val = rv.clone();
2461 let sv = rule_entry
2462 .values
2463 .entry(norm_rk.clone())
2464 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
2465 sv.push_override(toml_val, source, file.clone(), None);
2466 }
2467 }
2468 }
2469 }
2470 }
2471
2472 if let Some(doc_table) = doc.as_table() {
2474 for (key, value) in doc_table.iter() {
2475 if let Some(rule_name) = key.strip_prefix("tool.rumdl.") {
2476 let norm_rule_name = normalize_key(rule_name);
2477 if norm_rule_name.len() == 5
2478 && norm_rule_name.to_ascii_uppercase().starts_with("MD")
2479 && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
2480 && let Some(rule_table) = value.as_table()
2481 {
2482 let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
2483 for (rk, rv) in rule_table {
2484 let norm_rk = normalize_key(rk);
2485 let toml_val = rv.clone();
2486 let sv = rule_entry
2487 .values
2488 .entry(norm_rk.clone())
2489 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
2490 sv.push_override(toml_val, source, file.clone(), None);
2491 }
2492 }
2493 }
2494 }
2495 }
2496
2497 let has_any = !fragment.global.enable.value.is_empty()
2499 || !fragment.global.disable.value.is_empty()
2500 || !fragment.global.include.value.is_empty()
2501 || !fragment.global.exclude.value.is_empty()
2502 || !fragment.global.fixable.value.is_empty()
2503 || !fragment.global.unfixable.value.is_empty()
2504 || fragment.global.output_format.is_some()
2505 || !fragment.per_file_ignores.value.is_empty()
2506 || !fragment.rules.is_empty();
2507 if has_any { Ok(Some(fragment)) } else { Ok(None) }
2508}
2509
2510fn parse_rumdl_toml(content: &str, path: &str) -> Result<SourcedConfigFragment, ConfigError> {
2512 let doc = content
2513 .parse::<DocumentMut>()
2514 .map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
2515 let mut fragment = SourcedConfigFragment::default();
2516 let source = ConfigSource::RumdlToml;
2517 let file = Some(path.to_string());
2518
2519 let all_rules = rules::all_rules(&Config::default());
2521 let registry = RuleRegistry::from_rules(&all_rules);
2522 let known_rule_names: BTreeSet<String> = registry
2523 .rule_names()
2524 .into_iter()
2525 .map(|s| s.to_ascii_uppercase())
2526 .collect();
2527
2528 if let Some(global_item) = doc.get("global")
2530 && let Some(global_table) = global_item.as_table()
2531 {
2532 for (key, value_item) in global_table.iter() {
2533 let norm_key = normalize_key(key);
2534 match norm_key.as_str() {
2535 "enable" | "disable" | "include" | "exclude" => {
2536 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2537 let values: Vec<String> = formatted_array
2539 .iter()
2540 .filter_map(|item| item.as_str()) .map(|s| s.to_string())
2542 .collect();
2543
2544 let final_values = if norm_key == "enable" || norm_key == "disable" {
2546 values.into_iter().map(|s| normalize_key(&s)).collect()
2548 } else {
2549 values
2550 };
2551
2552 match norm_key.as_str() {
2553 "enable" => fragment
2554 .global
2555 .enable
2556 .push_override(final_values, source, file.clone(), None),
2557 "disable" => {
2558 fragment
2559 .global
2560 .disable
2561 .push_override(final_values, source, file.clone(), None)
2562 }
2563 "include" => {
2564 fragment
2565 .global
2566 .include
2567 .push_override(final_values, source, file.clone(), None)
2568 }
2569 "exclude" => {
2570 fragment
2571 .global
2572 .exclude
2573 .push_override(final_values, source, file.clone(), None)
2574 }
2575 _ => unreachable!(), }
2577 } else {
2578 log::warn!(
2579 "[WARN] Expected array for global key '{}' in {}, found {}",
2580 key,
2581 path,
2582 value_item.type_name()
2583 );
2584 }
2585 }
2586 "respect_gitignore" | "respect-gitignore" => {
2587 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
2589 let val = *formatted_bool.value();
2590 fragment
2591 .global
2592 .respect_gitignore
2593 .push_override(val, source, file.clone(), None);
2594 } else {
2595 log::warn!(
2596 "[WARN] Expected boolean for global key '{}' in {}, found {}",
2597 key,
2598 path,
2599 value_item.type_name()
2600 );
2601 }
2602 }
2603 "force_exclude" | "force-exclude" => {
2604 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
2606 let val = *formatted_bool.value();
2607 fragment
2608 .global
2609 .force_exclude
2610 .push_override(val, source, file.clone(), None);
2611 } else {
2612 log::warn!(
2613 "[WARN] Expected boolean for global key '{}' in {}, found {}",
2614 key,
2615 path,
2616 value_item.type_name()
2617 );
2618 }
2619 }
2620 "line_length" | "line-length" => {
2621 if let Some(toml_edit::Value::Integer(formatted_int)) = value_item.as_value() {
2623 let val = *formatted_int.value() as u64;
2624 fragment
2625 .global
2626 .line_length
2627 .push_override(val, source, file.clone(), None);
2628 } else {
2629 log::warn!(
2630 "[WARN] Expected integer for global key '{}' in {}, found {}",
2631 key,
2632 path,
2633 value_item.type_name()
2634 );
2635 }
2636 }
2637 "output_format" | "output-format" => {
2638 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
2640 let val = formatted_string.value().clone();
2641 if fragment.global.output_format.is_none() {
2642 fragment.global.output_format = Some(SourcedValue::new(val.clone(), source));
2643 } else {
2644 fragment.global.output_format.as_mut().unwrap().push_override(
2645 val,
2646 source,
2647 file.clone(),
2648 None,
2649 );
2650 }
2651 } else {
2652 log::warn!(
2653 "[WARN] Expected string for global key '{}' in {}, found {}",
2654 key,
2655 path,
2656 value_item.type_name()
2657 );
2658 }
2659 }
2660 "fixable" => {
2661 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2662 let values: Vec<String> = formatted_array
2663 .iter()
2664 .filter_map(|item| item.as_str())
2665 .map(normalize_key)
2666 .collect();
2667 fragment
2668 .global
2669 .fixable
2670 .push_override(values, source, file.clone(), None);
2671 } else {
2672 log::warn!(
2673 "[WARN] Expected array for global key '{}' in {}, found {}",
2674 key,
2675 path,
2676 value_item.type_name()
2677 );
2678 }
2679 }
2680 "unfixable" => {
2681 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2682 let values: Vec<String> = formatted_array
2683 .iter()
2684 .filter_map(|item| item.as_str())
2685 .map(normalize_key)
2686 .collect();
2687 fragment
2688 .global
2689 .unfixable
2690 .push_override(values, source, file.clone(), None);
2691 } else {
2692 log::warn!(
2693 "[WARN] Expected array for global key '{}' in {}, found {}",
2694 key,
2695 path,
2696 value_item.type_name()
2697 );
2698 }
2699 }
2700 "flavor" => {
2701 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
2702 let val = formatted_string.value();
2703 if let Ok(flavor) = MarkdownFlavor::from_str(val) {
2704 fragment.global.flavor.push_override(flavor, source, file.clone(), None);
2705 } else {
2706 log::warn!("[WARN] Unknown markdown flavor '{val}' in {path}");
2707 }
2708 } else {
2709 log::warn!(
2710 "[WARN] Expected string for global key '{}' in {}, found {}",
2711 key,
2712 path,
2713 value_item.type_name()
2714 );
2715 }
2716 }
2717 _ => {
2718 log::warn!("[WARN] Unknown key in [global] section of {path}: {key}");
2721 }
2722 }
2723 }
2724 }
2725
2726 if let Some(per_file_item) = doc.get("per-file-ignores")
2728 && let Some(per_file_table) = per_file_item.as_table()
2729 {
2730 let mut per_file_map = HashMap::new();
2731 for (pattern, value_item) in per_file_table.iter() {
2732 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2733 let rules: Vec<String> = formatted_array
2734 .iter()
2735 .filter_map(|item| item.as_str())
2736 .map(normalize_key)
2737 .collect();
2738 per_file_map.insert(pattern.to_string(), rules);
2739 } else {
2740 let type_name = value_item.type_name();
2741 log::warn!(
2742 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {type_name}"
2743 );
2744 }
2745 }
2746 fragment
2747 .per_file_ignores
2748 .push_override(per_file_map, source, file.clone(), None);
2749 }
2750
2751 for (key, item) in doc.iter() {
2753 let norm_rule_name = key.to_ascii_uppercase();
2754 if !known_rule_names.contains(&norm_rule_name) {
2755 continue;
2756 }
2757 if let Some(tbl) = item.as_table() {
2758 let rule_entry = fragment.rules.entry(norm_rule_name.clone()).or_default();
2759 for (rk, rv_item) in tbl.iter() {
2760 let norm_rk = normalize_key(rk);
2761 let maybe_toml_val: Option<toml::Value> = match rv_item.as_value() {
2762 Some(toml_edit::Value::String(formatted)) => Some(toml::Value::String(formatted.value().clone())),
2763 Some(toml_edit::Value::Integer(formatted)) => Some(toml::Value::Integer(*formatted.value())),
2764 Some(toml_edit::Value::Float(formatted)) => Some(toml::Value::Float(*formatted.value())),
2765 Some(toml_edit::Value::Boolean(formatted)) => Some(toml::Value::Boolean(*formatted.value())),
2766 Some(toml_edit::Value::Datetime(formatted)) => Some(toml::Value::Datetime(*formatted.value())),
2767 Some(toml_edit::Value::Array(formatted_array)) => {
2768 let mut values = Vec::new();
2770 for item in formatted_array.iter() {
2771 match item {
2772 toml_edit::Value::String(formatted) => {
2773 values.push(toml::Value::String(formatted.value().clone()))
2774 }
2775 toml_edit::Value::Integer(formatted) => {
2776 values.push(toml::Value::Integer(*formatted.value()))
2777 }
2778 toml_edit::Value::Float(formatted) => {
2779 values.push(toml::Value::Float(*formatted.value()))
2780 }
2781 toml_edit::Value::Boolean(formatted) => {
2782 values.push(toml::Value::Boolean(*formatted.value()))
2783 }
2784 toml_edit::Value::Datetime(formatted) => {
2785 values.push(toml::Value::Datetime(*formatted.value()))
2786 }
2787 _ => {
2788 log::warn!(
2789 "[WARN] Skipping unsupported array element type in key '{norm_rule_name}.{norm_rk}' in {path}"
2790 );
2791 }
2792 }
2793 }
2794 Some(toml::Value::Array(values))
2795 }
2796 Some(toml_edit::Value::InlineTable(_)) => {
2797 log::warn!(
2798 "[WARN] Skipping inline table value for key '{norm_rule_name}.{norm_rk}' in {path}. Table conversion not yet fully implemented in parser."
2799 );
2800 None
2801 }
2802 None => {
2803 log::warn!(
2804 "[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {path}. Expected simple value."
2805 );
2806 None
2807 }
2808 };
2809 if let Some(toml_val) = maybe_toml_val {
2810 let sv = rule_entry
2811 .values
2812 .entry(norm_rk.clone())
2813 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
2814 sv.push_override(toml_val, source, file.clone(), None);
2815 }
2816 }
2817 } else if item.is_value() {
2818 log::warn!("[WARN] Ignoring top-level value key in {path}: '{key}'. Expected a table like [{key}].");
2819 }
2820 }
2821
2822 Ok(fragment)
2823}
2824
2825fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
2827 let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
2829 .map_err(|e| ConfigError::ParseError(format!("{path}: {e}")))?;
2830 Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
2831}