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() -> Option<std::path::PathBuf> {
1712 use etcetera::{BaseStrategy, choose_base_strategy};
1713
1714 match choose_base_strategy() {
1715 Ok(strategy) => {
1716 let config_dir = strategy.config_dir().join("rumdl");
1717
1718 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
1720
1721 log::debug!(
1722 "[rumdl-config] Checking for user configuration in: {}",
1723 config_dir.display()
1724 );
1725
1726 for filename in USER_CONFIG_FILES {
1727 let config_path = config_dir.join(filename);
1728
1729 if config_path.exists() {
1730 if *filename == "pyproject.toml" {
1732 if let Ok(content) = std::fs::read_to_string(&config_path) {
1733 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
1734 log::debug!(
1735 "[rumdl-config] Found user configuration at: {}",
1736 config_path.display()
1737 );
1738 return Some(config_path);
1739 }
1740 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
1741 continue;
1742 }
1743 } else {
1744 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
1745 return Some(config_path);
1746 }
1747 }
1748 }
1749
1750 log::debug!(
1751 "[rumdl-config] No user configuration found in: {}",
1752 config_dir.display()
1753 );
1754 None
1755 }
1756 Err(e) => {
1757 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
1758 None
1759 }
1760 }
1761 }
1762
1763 pub fn load_with_discovery(
1766 config_path: Option<&str>,
1767 cli_overrides: Option<&SourcedGlobalConfig>,
1768 skip_auto_discovery: bool,
1769 ) -> Result<Self, ConfigError> {
1770 use std::env;
1771 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
1772 if config_path.is_none() {
1773 if skip_auto_discovery {
1774 log::debug!("[rumdl-config] Skipping auto-discovery due to --no-config flag");
1775 } else {
1776 log::debug!("[rumdl-config] No explicit config_path provided, will search default locations");
1777 }
1778 } else {
1779 log::debug!("[rumdl-config] Explicit config_path provided: {config_path:?}");
1780 }
1781 let mut sourced_config = SourcedConfig::default();
1782
1783 if let Some(path) = config_path {
1785 let path_obj = Path::new(path);
1786 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
1787 log::debug!("[rumdl-config] Trying to load config file: {filename}");
1788 let path_str = path.to_string();
1789
1790 const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
1792
1793 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
1794 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
1795 source: e,
1796 path: path_str.clone(),
1797 })?;
1798 if filename == "pyproject.toml" {
1799 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
1800 sourced_config.merge(fragment);
1801 sourced_config.loaded_files.push(path_str.clone());
1802 }
1803 } else {
1804 let fragment = parse_rumdl_toml(&content, &path_str)?;
1805 sourced_config.merge(fragment);
1806 sourced_config.loaded_files.push(path_str.clone());
1807 }
1808 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
1809 || path_str.ends_with(".json")
1810 || path_str.ends_with(".jsonc")
1811 || path_str.ends_with(".yaml")
1812 || path_str.ends_with(".yml")
1813 {
1814 let fragment = load_from_markdownlint(&path_str)?;
1816 sourced_config.merge(fragment);
1817 sourced_config.loaded_files.push(path_str.clone());
1818 } else {
1820 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
1822 source: e,
1823 path: path_str.clone(),
1824 })?;
1825 let fragment = parse_rumdl_toml(&content, &path_str)?;
1826 sourced_config.merge(fragment);
1827 sourced_config.loaded_files.push(path_str.clone());
1828 }
1829 }
1830
1831 if !skip_auto_discovery && config_path.is_none() {
1833 if let Some(user_config_path) = Self::user_configuration_path() {
1835 let path_str = user_config_path.display().to_string();
1836 let filename = user_config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1837
1838 log::debug!("[rumdl-config] Loading user configuration file: {path_str}");
1839
1840 if filename == "pyproject.toml" {
1841 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
1842 source: e,
1843 path: path_str.clone(),
1844 })?;
1845 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
1846 sourced_config.merge(fragment);
1847 sourced_config.loaded_files.push(path_str);
1848 }
1849 } else {
1850 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
1851 source: e,
1852 path: path_str.clone(),
1853 })?;
1854 let fragment = parse_rumdl_toml(&content, &path_str)?;
1855 sourced_config.merge(fragment);
1856 sourced_config.loaded_files.push(path_str);
1857 }
1858 } else {
1859 log::debug!("[rumdl-config] No user configuration file found");
1860 }
1861
1862 if let Some(config_file) = Self::discover_config_upward() {
1864 let path_str = config_file.display().to_string();
1865 let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
1866
1867 log::debug!("[rumdl-config] Loading discovered config file: {path_str}");
1868
1869 if filename == "pyproject.toml" {
1870 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
1871 source: e,
1872 path: path_str.clone(),
1873 })?;
1874 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
1875 sourced_config.merge(fragment);
1876 sourced_config.loaded_files.push(path_str);
1877 }
1878 } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
1879 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
1880 source: e,
1881 path: path_str.clone(),
1882 })?;
1883 let fragment = parse_rumdl_toml(&content, &path_str)?;
1884 sourced_config.merge(fragment);
1885 sourced_config.loaded_files.push(path_str);
1886 }
1887 } else {
1888 log::debug!("[rumdl-config] No configuration file found via upward traversal");
1889
1890 let mut found_markdownlint = false;
1892 for filename in MARKDOWNLINT_CONFIG_FILES {
1893 if std::path::Path::new(filename).exists() {
1894 match load_from_markdownlint(filename) {
1895 Ok(fragment) => {
1896 sourced_config.merge(fragment);
1897 sourced_config.loaded_files.push(filename.to_string());
1898 found_markdownlint = true;
1899 break; }
1901 Err(_e) => {
1902 }
1904 }
1905 }
1906 }
1907
1908 if !found_markdownlint {
1909 log::debug!("[rumdl-config] No markdownlint configuration file found");
1910 }
1911 }
1912 }
1913
1914 if let Some(cli) = cli_overrides {
1916 sourced_config
1917 .global
1918 .enable
1919 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
1920 sourced_config
1921 .global
1922 .disable
1923 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
1924 sourced_config
1925 .global
1926 .exclude
1927 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
1928 sourced_config
1929 .global
1930 .include
1931 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
1932 sourced_config.global.respect_gitignore.merge_override(
1933 cli.respect_gitignore.value,
1934 ConfigSource::Cli,
1935 None,
1936 None,
1937 );
1938 sourced_config
1939 .global
1940 .fixable
1941 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
1942 sourced_config
1943 .global
1944 .unfixable
1945 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
1946 }
1948
1949 Ok(sourced_config)
1952 }
1953}
1954
1955impl From<SourcedConfig> for Config {
1956 fn from(sourced: SourcedConfig) -> Self {
1957 let mut rules = BTreeMap::new();
1958 for (rule_name, sourced_rule_cfg) in sourced.rules {
1959 let normalized_rule_name = rule_name.to_ascii_uppercase();
1961 let mut values = BTreeMap::new();
1962 for (key, sourced_val) in sourced_rule_cfg.values {
1963 values.insert(key, sourced_val.value);
1964 }
1965 rules.insert(normalized_rule_name, RuleConfig { values });
1966 }
1967 #[allow(deprecated)]
1968 let global = GlobalConfig {
1969 enable: sourced.global.enable.value,
1970 disable: sourced.global.disable.value,
1971 exclude: sourced.global.exclude.value,
1972 include: sourced.global.include.value,
1973 respect_gitignore: sourced.global.respect_gitignore.value,
1974 line_length: sourced.global.line_length.value,
1975 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
1976 fixable: sourced.global.fixable.value,
1977 unfixable: sourced.global.unfixable.value,
1978 flavor: sourced.global.flavor.value,
1979 force_exclude: sourced.global.force_exclude.value,
1980 };
1981 Config {
1982 global,
1983 per_file_ignores: sourced.per_file_ignores.value,
1984 rules,
1985 }
1986 }
1987}
1988
1989pub struct RuleRegistry {
1991 pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
1993 pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
1995}
1996
1997impl RuleRegistry {
1998 pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
2000 let mut rule_schemas = std::collections::BTreeMap::new();
2001 let mut rule_aliases = std::collections::BTreeMap::new();
2002
2003 for rule in rules {
2004 let norm_name = if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
2005 let norm_name = normalize_key(&name); rule_schemas.insert(norm_name.clone(), table);
2007 norm_name
2008 } else {
2009 let norm_name = normalize_key(rule.name()); rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
2011 norm_name
2012 };
2013
2014 if let Some(aliases) = rule.config_aliases() {
2016 rule_aliases.insert(norm_name, aliases);
2017 }
2018 }
2019
2020 RuleRegistry {
2021 rule_schemas,
2022 rule_aliases,
2023 }
2024 }
2025
2026 pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
2028 self.rule_schemas.keys().cloned().collect()
2029 }
2030
2031 pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
2033 self.rule_schemas.get(rule).map(|schema| {
2034 let mut all_keys = std::collections::BTreeSet::new();
2035
2036 for key in schema.keys() {
2038 all_keys.insert(key.clone());
2039 }
2040
2041 for key in schema.keys() {
2043 all_keys.insert(key.replace('_', "-"));
2045 all_keys.insert(key.replace('-', "_"));
2047 all_keys.insert(normalize_key(key));
2049 }
2050
2051 if let Some(aliases) = self.rule_aliases.get(rule) {
2053 for alias_key in aliases.keys() {
2054 all_keys.insert(alias_key.clone());
2055 all_keys.insert(alias_key.replace('_', "-"));
2057 all_keys.insert(alias_key.replace('-', "_"));
2058 all_keys.insert(normalize_key(alias_key));
2059 }
2060 }
2061
2062 all_keys
2063 })
2064 }
2065
2066 pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
2068 if let Some(schema) = self.rule_schemas.get(rule) {
2069 if let Some(aliases) = self.rule_aliases.get(rule)
2071 && let Some(canonical_key) = aliases.get(key)
2072 {
2073 if let Some(value) = schema.get(canonical_key) {
2075 return Some(value);
2076 }
2077 }
2078
2079 if let Some(value) = schema.get(key) {
2081 return Some(value);
2082 }
2083
2084 let key_variants = [
2086 key.replace('-', "_"), key.replace('_', "-"), normalize_key(key), ];
2090
2091 for variant in &key_variants {
2092 if let Some(value) = schema.get(variant) {
2093 return Some(value);
2094 }
2095 }
2096 }
2097 None
2098 }
2099}
2100
2101#[derive(Debug, Clone)]
2103pub struct ConfigValidationWarning {
2104 pub message: String,
2105 pub rule: Option<String>,
2106 pub key: Option<String>,
2107}
2108
2109pub fn validate_config_sourced(sourced: &SourcedConfig, registry: &RuleRegistry) -> Vec<ConfigValidationWarning> {
2111 let mut warnings = Vec::new();
2112 let known_rules = registry.rule_names();
2113 for rule in sourced.rules.keys() {
2115 if !known_rules.contains(rule) {
2116 warnings.push(ConfigValidationWarning {
2117 message: format!("Unknown rule in config: {rule}"),
2118 rule: Some(rule.clone()),
2119 key: None,
2120 });
2121 }
2122 }
2123 for (rule, rule_cfg) in &sourced.rules {
2125 if let Some(valid_keys) = registry.config_keys_for(rule) {
2126 for key in rule_cfg.values.keys() {
2127 if !valid_keys.contains(key) {
2128 warnings.push(ConfigValidationWarning {
2129 message: format!("Unknown option for rule {rule}: {key}"),
2130 rule: Some(rule.clone()),
2131 key: Some(key.clone()),
2132 });
2133 } else {
2134 if let Some(expected) = registry.expected_value_for(rule, key) {
2136 let actual = &rule_cfg.values[key].value;
2137 if !toml_value_type_matches(expected, actual) {
2138 warnings.push(ConfigValidationWarning {
2139 message: format!(
2140 "Type mismatch for {}.{}: expected {}, got {}",
2141 rule,
2142 key,
2143 toml_type_name(expected),
2144 toml_type_name(actual)
2145 ),
2146 rule: Some(rule.clone()),
2147 key: Some(key.clone()),
2148 });
2149 }
2150 }
2151 }
2152 }
2153 }
2154 }
2155 for (section, key) in &sourced.unknown_keys {
2157 if section.contains("[global]") {
2158 warnings.push(ConfigValidationWarning {
2159 message: format!("Unknown global option: {key}"),
2160 rule: None,
2161 key: Some(key.clone()),
2162 });
2163 }
2164 }
2165 warnings
2166}
2167
2168fn toml_type_name(val: &toml::Value) -> &'static str {
2169 match val {
2170 toml::Value::String(_) => "string",
2171 toml::Value::Integer(_) => "integer",
2172 toml::Value::Float(_) => "float",
2173 toml::Value::Boolean(_) => "boolean",
2174 toml::Value::Array(_) => "array",
2175 toml::Value::Table(_) => "table",
2176 toml::Value::Datetime(_) => "datetime",
2177 }
2178}
2179
2180fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
2181 use toml::Value::*;
2182 match (expected, actual) {
2183 (String(_), String(_)) => true,
2184 (Integer(_), Integer(_)) => true,
2185 (Float(_), Float(_)) => true,
2186 (Boolean(_), Boolean(_)) => true,
2187 (Array(_), Array(_)) => true,
2188 (Table(_), Table(_)) => true,
2189 (Datetime(_), Datetime(_)) => true,
2190 (Float(_), Integer(_)) => true,
2192 _ => false,
2193 }
2194}
2195
2196fn parse_pyproject_toml(content: &str, path: &str) -> Result<Option<SourcedConfigFragment>, ConfigError> {
2198 let doc: toml::Value =
2199 toml::from_str(content).map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
2200 let mut fragment = SourcedConfigFragment::default();
2201 let source = ConfigSource::PyprojectToml;
2202 let file = Some(path.to_string());
2203
2204 if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
2206 && let Some(rumdl_table) = rumdl_config.as_table()
2207 {
2208 let extract_global_config = |fragment: &mut SourcedConfigFragment, table: &toml::value::Table| {
2210 if let Some(enable) = table.get("enable")
2212 && let Ok(values) = Vec::<String>::deserialize(enable.clone())
2213 {
2214 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
2216 fragment
2217 .global
2218 .enable
2219 .push_override(normalized_values, source, file.clone(), None);
2220 }
2221
2222 if let Some(disable) = table.get("disable")
2223 && let Ok(values) = Vec::<String>::deserialize(disable.clone())
2224 {
2225 let normalized_values: Vec<String> = values.into_iter().map(|s| normalize_key(&s)).collect();
2227 fragment
2228 .global
2229 .disable
2230 .push_override(normalized_values, source, file.clone(), None);
2231 }
2232
2233 if let Some(include) = table.get("include")
2234 && let Ok(values) = Vec::<String>::deserialize(include.clone())
2235 {
2236 fragment
2237 .global
2238 .include
2239 .push_override(values, source, file.clone(), None);
2240 }
2241
2242 if let Some(exclude) = table.get("exclude")
2243 && let Ok(values) = Vec::<String>::deserialize(exclude.clone())
2244 {
2245 fragment
2246 .global
2247 .exclude
2248 .push_override(values, source, file.clone(), None);
2249 }
2250
2251 if let Some(respect_gitignore) = table
2252 .get("respect-gitignore")
2253 .or_else(|| table.get("respect_gitignore"))
2254 && let Ok(value) = bool::deserialize(respect_gitignore.clone())
2255 {
2256 fragment
2257 .global
2258 .respect_gitignore
2259 .push_override(value, source, file.clone(), None);
2260 }
2261
2262 if let Some(force_exclude) = table.get("force-exclude").or_else(|| table.get("force_exclude"))
2263 && let Ok(value) = bool::deserialize(force_exclude.clone())
2264 {
2265 fragment
2266 .global
2267 .force_exclude
2268 .push_override(value, source, file.clone(), None);
2269 }
2270
2271 if let Some(output_format) = table.get("output-format").or_else(|| table.get("output_format"))
2272 && let Ok(value) = String::deserialize(output_format.clone())
2273 {
2274 if fragment.global.output_format.is_none() {
2275 fragment.global.output_format = Some(SourcedValue::new(value.clone(), source));
2276 } else {
2277 fragment
2278 .global
2279 .output_format
2280 .as_mut()
2281 .unwrap()
2282 .push_override(value, source, file.clone(), None);
2283 }
2284 }
2285
2286 if let Some(fixable) = table.get("fixable")
2287 && let Ok(values) = Vec::<String>::deserialize(fixable.clone())
2288 {
2289 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
2290 fragment
2291 .global
2292 .fixable
2293 .push_override(normalized_values, source, file.clone(), None);
2294 }
2295
2296 if let Some(unfixable) = table.get("unfixable")
2297 && let Ok(values) = Vec::<String>::deserialize(unfixable.clone())
2298 {
2299 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
2300 fragment
2301 .global
2302 .unfixable
2303 .push_override(normalized_values, source, file.clone(), None);
2304 }
2305
2306 if let Some(flavor) = table.get("flavor")
2307 && let Ok(value) = MarkdownFlavor::deserialize(flavor.clone())
2308 {
2309 fragment.global.flavor.push_override(value, source, file.clone(), None);
2310 }
2311
2312 if let Some(line_length) = table.get("line-length").or_else(|| table.get("line_length"))
2314 && let Ok(value) = u64::deserialize(line_length.clone())
2315 {
2316 fragment
2317 .global
2318 .line_length
2319 .push_override(value, source, file.clone(), None);
2320
2321 let norm_md013_key = normalize_key("MD013");
2323 let rule_entry = fragment.rules.entry(norm_md013_key).or_default();
2324 let norm_line_length_key = normalize_key("line-length");
2325 let sv = rule_entry
2326 .values
2327 .entry(norm_line_length_key)
2328 .or_insert_with(|| SourcedValue::new(line_length.clone(), ConfigSource::Default));
2329 sv.push_override(line_length.clone(), source, file.clone(), None);
2330 }
2331 };
2332
2333 if let Some(global_table) = rumdl_table.get("global").and_then(|g| g.as_table()) {
2335 extract_global_config(&mut fragment, global_table);
2336 }
2337
2338 extract_global_config(&mut fragment, rumdl_table);
2340
2341 let per_file_ignores_key = rumdl_table
2344 .get("per-file-ignores")
2345 .or_else(|| rumdl_table.get("per_file_ignores"));
2346
2347 if let Some(per_file_ignores_value) = per_file_ignores_key
2348 && let Some(per_file_table) = per_file_ignores_value.as_table()
2349 {
2350 let mut per_file_map = HashMap::new();
2351 for (pattern, rules_value) in per_file_table {
2352 if let Ok(rules) = Vec::<String>::deserialize(rules_value.clone()) {
2353 let normalized_rules = rules.into_iter().map(|s| normalize_key(&s)).collect();
2354 per_file_map.insert(pattern.clone(), normalized_rules);
2355 } else {
2356 log::warn!(
2357 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {rules_value:?}"
2358 );
2359 }
2360 }
2361 fragment
2362 .per_file_ignores
2363 .push_override(per_file_map, source, file.clone(), None);
2364 }
2365
2366 for (key, value) in rumdl_table {
2368 let norm_rule_key = normalize_key(key);
2369
2370 if [
2372 "enable",
2373 "disable",
2374 "include",
2375 "exclude",
2376 "respect_gitignore",
2377 "respect-gitignore", "force_exclude",
2379 "force-exclude",
2380 "line_length",
2381 "line-length",
2382 "output_format",
2383 "output-format",
2384 "fixable",
2385 "unfixable",
2386 "per-file-ignores",
2387 "per_file_ignores",
2388 "global",
2389 ]
2390 .contains(&norm_rule_key.as_str())
2391 {
2392 continue;
2393 }
2394
2395 let norm_rule_key_upper = norm_rule_key.to_ascii_uppercase();
2399 if norm_rule_key_upper.len() == 5
2400 && norm_rule_key_upper.starts_with("MD")
2401 && norm_rule_key_upper[2..].chars().all(|c| c.is_ascii_digit())
2402 && value.is_table()
2403 {
2404 if let Some(rule_config_table) = value.as_table() {
2405 let rule_entry = fragment.rules.entry(norm_rule_key_upper).or_default();
2407 for (rk, rv) in rule_config_table {
2408 let norm_rk = normalize_key(rk); let toml_val = rv.clone();
2411
2412 let sv = rule_entry
2413 .values
2414 .entry(norm_rk.clone())
2415 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
2416 sv.push_override(toml_val, source, file.clone(), None);
2417 }
2418 }
2419 } else {
2420 }
2424 }
2425 }
2426
2427 if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
2429 for (key, value) in tool_table.iter() {
2430 if let Some(rule_name) = key.strip_prefix("rumdl.") {
2431 let norm_rule_name = normalize_key(rule_name);
2432 if norm_rule_name.len() == 5
2433 && norm_rule_name.to_ascii_uppercase().starts_with("MD")
2434 && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
2435 && let Some(rule_table) = value.as_table()
2436 {
2437 let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
2438 for (rk, rv) in rule_table {
2439 let norm_rk = normalize_key(rk);
2440 let toml_val = rv.clone();
2441 let sv = rule_entry
2442 .values
2443 .entry(norm_rk.clone())
2444 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
2445 sv.push_override(toml_val, source, file.clone(), None);
2446 }
2447 }
2448 }
2449 }
2450 }
2451
2452 if let Some(doc_table) = doc.as_table() {
2454 for (key, value) in doc_table.iter() {
2455 if let Some(rule_name) = key.strip_prefix("tool.rumdl.") {
2456 let norm_rule_name = normalize_key(rule_name);
2457 if norm_rule_name.len() == 5
2458 && norm_rule_name.to_ascii_uppercase().starts_with("MD")
2459 && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
2460 && let Some(rule_table) = value.as_table()
2461 {
2462 let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
2463 for (rk, rv) in rule_table {
2464 let norm_rk = normalize_key(rk);
2465 let toml_val = rv.clone();
2466 let sv = rule_entry
2467 .values
2468 .entry(norm_rk.clone())
2469 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
2470 sv.push_override(toml_val, source, file.clone(), None);
2471 }
2472 }
2473 }
2474 }
2475 }
2476
2477 let has_any = !fragment.global.enable.value.is_empty()
2479 || !fragment.global.disable.value.is_empty()
2480 || !fragment.global.include.value.is_empty()
2481 || !fragment.global.exclude.value.is_empty()
2482 || !fragment.global.fixable.value.is_empty()
2483 || !fragment.global.unfixable.value.is_empty()
2484 || fragment.global.output_format.is_some()
2485 || !fragment.per_file_ignores.value.is_empty()
2486 || !fragment.rules.is_empty();
2487 if has_any { Ok(Some(fragment)) } else { Ok(None) }
2488}
2489
2490fn parse_rumdl_toml(content: &str, path: &str) -> Result<SourcedConfigFragment, ConfigError> {
2492 let doc = content
2493 .parse::<DocumentMut>()
2494 .map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
2495 let mut fragment = SourcedConfigFragment::default();
2496 let source = ConfigSource::RumdlToml;
2497 let file = Some(path.to_string());
2498
2499 let all_rules = rules::all_rules(&Config::default());
2501 let registry = RuleRegistry::from_rules(&all_rules);
2502 let known_rule_names: BTreeSet<String> = registry
2503 .rule_names()
2504 .into_iter()
2505 .map(|s| s.to_ascii_uppercase())
2506 .collect();
2507
2508 if let Some(global_item) = doc.get("global")
2510 && let Some(global_table) = global_item.as_table()
2511 {
2512 for (key, value_item) in global_table.iter() {
2513 let norm_key = normalize_key(key);
2514 match norm_key.as_str() {
2515 "enable" | "disable" | "include" | "exclude" => {
2516 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2517 let values: Vec<String> = formatted_array
2519 .iter()
2520 .filter_map(|item| item.as_str()) .map(|s| s.to_string())
2522 .collect();
2523
2524 let final_values = if norm_key == "enable" || norm_key == "disable" {
2526 values.into_iter().map(|s| normalize_key(&s)).collect()
2528 } else {
2529 values
2530 };
2531
2532 match norm_key.as_str() {
2533 "enable" => fragment
2534 .global
2535 .enable
2536 .push_override(final_values, source, file.clone(), None),
2537 "disable" => {
2538 fragment
2539 .global
2540 .disable
2541 .push_override(final_values, source, file.clone(), None)
2542 }
2543 "include" => {
2544 fragment
2545 .global
2546 .include
2547 .push_override(final_values, source, file.clone(), None)
2548 }
2549 "exclude" => {
2550 fragment
2551 .global
2552 .exclude
2553 .push_override(final_values, source, file.clone(), None)
2554 }
2555 _ => unreachable!(), }
2557 } else {
2558 log::warn!(
2559 "[WARN] Expected array for global key '{}' in {}, found {}",
2560 key,
2561 path,
2562 value_item.type_name()
2563 );
2564 }
2565 }
2566 "respect_gitignore" | "respect-gitignore" => {
2567 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
2569 let val = *formatted_bool.value();
2570 fragment
2571 .global
2572 .respect_gitignore
2573 .push_override(val, source, file.clone(), None);
2574 } else {
2575 log::warn!(
2576 "[WARN] Expected boolean for global key '{}' in {}, found {}",
2577 key,
2578 path,
2579 value_item.type_name()
2580 );
2581 }
2582 }
2583 "force_exclude" | "force-exclude" => {
2584 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
2586 let val = *formatted_bool.value();
2587 fragment
2588 .global
2589 .force_exclude
2590 .push_override(val, source, file.clone(), None);
2591 } else {
2592 log::warn!(
2593 "[WARN] Expected boolean for global key '{}' in {}, found {}",
2594 key,
2595 path,
2596 value_item.type_name()
2597 );
2598 }
2599 }
2600 "line_length" | "line-length" => {
2601 if let Some(toml_edit::Value::Integer(formatted_int)) = value_item.as_value() {
2603 let val = *formatted_int.value() as u64;
2604 fragment
2605 .global
2606 .line_length
2607 .push_override(val, source, file.clone(), None);
2608 } else {
2609 log::warn!(
2610 "[WARN] Expected integer for global key '{}' in {}, found {}",
2611 key,
2612 path,
2613 value_item.type_name()
2614 );
2615 }
2616 }
2617 "output_format" | "output-format" => {
2618 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
2620 let val = formatted_string.value().clone();
2621 if fragment.global.output_format.is_none() {
2622 fragment.global.output_format = Some(SourcedValue::new(val.clone(), source));
2623 } else {
2624 fragment.global.output_format.as_mut().unwrap().push_override(
2625 val,
2626 source,
2627 file.clone(),
2628 None,
2629 );
2630 }
2631 } else {
2632 log::warn!(
2633 "[WARN] Expected string for global key '{}' in {}, found {}",
2634 key,
2635 path,
2636 value_item.type_name()
2637 );
2638 }
2639 }
2640 "fixable" => {
2641 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2642 let values: Vec<String> = formatted_array
2643 .iter()
2644 .filter_map(|item| item.as_str())
2645 .map(normalize_key)
2646 .collect();
2647 fragment
2648 .global
2649 .fixable
2650 .push_override(values, source, file.clone(), None);
2651 } else {
2652 log::warn!(
2653 "[WARN] Expected array for global key '{}' in {}, found {}",
2654 key,
2655 path,
2656 value_item.type_name()
2657 );
2658 }
2659 }
2660 "unfixable" => {
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 .unfixable
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 "flavor" => {
2681 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
2682 let val = formatted_string.value();
2683 if let Ok(flavor) = MarkdownFlavor::from_str(val) {
2684 fragment.global.flavor.push_override(flavor, source, file.clone(), None);
2685 } else {
2686 log::warn!("[WARN] Unknown markdown flavor '{val}' in {path}");
2687 }
2688 } else {
2689 log::warn!(
2690 "[WARN] Expected string for global key '{}' in {}, found {}",
2691 key,
2692 path,
2693 value_item.type_name()
2694 );
2695 }
2696 }
2697 _ => {
2698 log::warn!("[WARN] Unknown key in [global] section of {path}: {key}");
2701 }
2702 }
2703 }
2704 }
2705
2706 if let Some(per_file_item) = doc.get("per-file-ignores")
2708 && let Some(per_file_table) = per_file_item.as_table()
2709 {
2710 let mut per_file_map = HashMap::new();
2711 for (pattern, value_item) in per_file_table.iter() {
2712 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2713 let rules: Vec<String> = formatted_array
2714 .iter()
2715 .filter_map(|item| item.as_str())
2716 .map(normalize_key)
2717 .collect();
2718 per_file_map.insert(pattern.to_string(), rules);
2719 } else {
2720 let type_name = value_item.type_name();
2721 log::warn!(
2722 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {type_name}"
2723 );
2724 }
2725 }
2726 fragment
2727 .per_file_ignores
2728 .push_override(per_file_map, source, file.clone(), None);
2729 }
2730
2731 for (key, item) in doc.iter() {
2733 let norm_rule_name = key.to_ascii_uppercase();
2734 if !known_rule_names.contains(&norm_rule_name) {
2735 continue;
2736 }
2737 if let Some(tbl) = item.as_table() {
2738 let rule_entry = fragment.rules.entry(norm_rule_name.clone()).or_default();
2739 for (rk, rv_item) in tbl.iter() {
2740 let norm_rk = normalize_key(rk);
2741 let maybe_toml_val: Option<toml::Value> = match rv_item.as_value() {
2742 Some(toml_edit::Value::String(formatted)) => Some(toml::Value::String(formatted.value().clone())),
2743 Some(toml_edit::Value::Integer(formatted)) => Some(toml::Value::Integer(*formatted.value())),
2744 Some(toml_edit::Value::Float(formatted)) => Some(toml::Value::Float(*formatted.value())),
2745 Some(toml_edit::Value::Boolean(formatted)) => Some(toml::Value::Boolean(*formatted.value())),
2746 Some(toml_edit::Value::Datetime(formatted)) => Some(toml::Value::Datetime(*formatted.value())),
2747 Some(toml_edit::Value::Array(formatted_array)) => {
2748 let mut values = Vec::new();
2750 for item in formatted_array.iter() {
2751 match item {
2752 toml_edit::Value::String(formatted) => {
2753 values.push(toml::Value::String(formatted.value().clone()))
2754 }
2755 toml_edit::Value::Integer(formatted) => {
2756 values.push(toml::Value::Integer(*formatted.value()))
2757 }
2758 toml_edit::Value::Float(formatted) => {
2759 values.push(toml::Value::Float(*formatted.value()))
2760 }
2761 toml_edit::Value::Boolean(formatted) => {
2762 values.push(toml::Value::Boolean(*formatted.value()))
2763 }
2764 toml_edit::Value::Datetime(formatted) => {
2765 values.push(toml::Value::Datetime(*formatted.value()))
2766 }
2767 _ => {
2768 log::warn!(
2769 "[WARN] Skipping unsupported array element type in key '{norm_rule_name}.{norm_rk}' in {path}"
2770 );
2771 }
2772 }
2773 }
2774 Some(toml::Value::Array(values))
2775 }
2776 Some(toml_edit::Value::InlineTable(_)) => {
2777 log::warn!(
2778 "[WARN] Skipping inline table value for key '{norm_rule_name}.{norm_rk}' in {path}. Table conversion not yet fully implemented in parser."
2779 );
2780 None
2781 }
2782 None => {
2783 log::warn!(
2784 "[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {path}. Expected simple value."
2785 );
2786 None
2787 }
2788 };
2789 if let Some(toml_val) = maybe_toml_val {
2790 let sv = rule_entry
2791 .values
2792 .entry(norm_rk.clone())
2793 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
2794 sv.push_override(toml_val, source, file.clone(), None);
2795 }
2796 }
2797 } else if item.is_value() {
2798 log::warn!("[WARN] Ignoring top-level value key in {path}: '{key}'. Expected a table like [{key}].");
2799 }
2800 }
2801
2802 Ok(fragment)
2803}
2804
2805fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
2807 let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
2809 .map_err(|e| ConfigError::ParseError(format!("{path}: {e}")))?;
2810 Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
2811}