1use crate::types::LineLength;
2use globset::{Glob, GlobBuilder, GlobMatcher, GlobSet, GlobSetBuilder};
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, HashSet};
6use std::fs;
7use std::io;
8use std::path::{Path, PathBuf};
9use std::sync::{Arc, OnceLock};
10
11use super::flavor::{MarkdownFlavor, normalize_key};
12
13#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
15pub struct RuleConfig {
16 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub severity: Option<crate::rule::Severity>,
19
20 #[serde(flatten)]
22 #[schemars(schema_with = "arbitrary_value_schema")]
23 pub values: BTreeMap<String, toml::Value>,
24}
25
26fn arbitrary_value_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
28 schemars::json_schema!({
29 "type": "object",
30 "additionalProperties": true
31 })
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
36#[schemars(
37 description = "rumdl configuration for linting Markdown files. Rules can be configured individually using [MD###] sections with rule-specific options."
38)]
39pub struct Config {
40 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub extends: Option<String>,
46
47 #[serde(default)]
49 pub global: GlobalConfig,
50
51 #[serde(default, rename = "per-file-ignores")]
56 pub per_file_ignores: BTreeMap<String, Vec<String>>,
57
58 #[serde(default, rename = "per-file-flavor")]
64 #[schemars(with = "BTreeMap<String, MarkdownFlavor>")]
65 pub per_file_flavor: IndexMap<String, MarkdownFlavor>,
66
67 #[serde(default, rename = "code-block-tools")]
70 pub code_block_tools: crate::code_block_tools::CodeBlockToolsConfig,
71
72 #[serde(flatten)]
83 pub rules: BTreeMap<String, RuleConfig>,
84
85 #[serde(skip)]
87 pub project_root: Option<std::path::PathBuf>,
88
89 #[serde(skip)]
90 #[schemars(skip)]
91 pub(super) per_file_ignores_cache: Arc<OnceLock<PerFileIgnoreCache>>,
92
93 #[serde(skip)]
94 #[schemars(skip)]
95 pub(super) per_file_flavor_cache: Arc<OnceLock<PerFileFlavorCache>>,
96
97 #[serde(skip)]
123 #[schemars(skip)]
124 pub(super) canonical_project_root_cache: Arc<OnceLock<Option<PathBuf>>>,
125}
126
127impl PartialEq for Config {
128 fn eq(&self, other: &Self) -> bool {
129 self.global == other.global
130 && self.per_file_ignores == other.per_file_ignores
131 && self.per_file_flavor == other.per_file_flavor
132 && self.code_block_tools == other.code_block_tools
133 && self.rules == other.rules
134 && self.project_root == other.project_root
135 }
136}
137
138#[derive(Debug)]
139pub(super) struct PerFileIgnoreCache {
140 globset: GlobSet,
141 rules: Vec<Vec<String>>,
142 has_absolute: bool,
145}
146
147#[derive(Debug)]
148pub(super) struct PerFileFlavorCache {
149 matchers: Vec<(GlobMatcher, MarkdownFlavor)>,
150 has_absolute: bool,
152}
153
154fn absolute_match_path(file_path: &Path, has_absolute: bool) -> Option<PathBuf> {
158 if !has_absolute {
159 return None;
160 }
161 crate::discovery::canonicalize_for_matching(file_path)
162}
163
164impl Config {
165 pub fn is_mkdocs_flavor(&self) -> bool {
167 self.global.flavor == MarkdownFlavor::MkDocs
168 }
169
170 pub fn markdown_flavor(&self) -> MarkdownFlavor {
176 self.global.flavor
177 }
178
179 pub fn is_mkdocs_project(&self) -> bool {
181 self.is_mkdocs_flavor()
182 }
183
184 pub fn apply_per_rule_enabled(&mut self) {
195 let mut to_enable: Vec<String> = Vec::new();
196 let mut to_disable: Vec<String> = Vec::new();
197
198 for (name, cfg) in &self.rules {
199 match cfg.values.get("enabled") {
200 Some(toml::Value::Boolean(true)) => {
201 to_enable.push(name.clone());
202 }
203 Some(toml::Value::Boolean(false)) => {
204 to_disable.push(name.clone());
205 }
206 _ => {}
207 }
208 }
209
210 for name in to_enable {
211 if !self.global.extend_enable.contains(&name) {
212 self.global.extend_enable.push(name.clone());
213 }
214 self.global.disable.retain(|n| n != &name);
215 self.global.extend_disable.retain(|n| n != &name);
216 }
217
218 for name in to_disable {
219 if !self.global.disable.contains(&name) {
220 self.global.disable.push(name.clone());
221 }
222 self.global.extend_enable.retain(|n| n != &name);
223 }
224 }
225
226 pub fn get_rule_severity(&self, rule_name: &str) -> Option<crate::rule::Severity> {
228 self.rules.get(rule_name).and_then(|r| r.severity)
229 }
230
231 pub(super) fn canonical_project_root(&self) -> Option<&Path> {
238 self.canonical_project_root_cache
239 .get_or_init(|| self.project_root.as_deref().and_then(|p| p.canonicalize().ok()))
240 .as_deref()
241 }
242
243 pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
246 let mut ignored_rules = HashSet::new();
247
248 if self.per_file_ignores.is_empty() {
249 return ignored_rules;
250 }
251
252 let cwd = std::env::current_dir().ok();
253 let path_for_matching = normalize_match_path(file_path, self.canonical_project_root(), cwd.as_deref());
254
255 let cache = self
256 .per_file_ignores_cache
257 .get_or_init(|| PerFileIgnoreCache::new(&self.per_file_ignores));
258
259 let absolute = absolute_match_path(file_path, cache.has_absolute);
263 let matches = cache
264 .globset
265 .matches(path_for_matching.as_ref())
266 .into_iter()
267 .chain(absolute.iter().flat_map(|abs| cache.globset.matches(abs)));
268
269 for match_idx in matches {
270 if let Some(rules) = cache.rules.get(match_idx) {
271 for rule in rules {
272 ignored_rules.insert(rule.clone());
274 }
275 }
276 }
277
278 ignored_rules
279 }
280
281 pub fn get_flavor_for_file(&self, file_path: &Path) -> MarkdownFlavor {
285 if self.per_file_flavor.is_empty() {
287 return self.resolve_flavor_fallback(file_path);
288 }
289
290 let cwd = std::env::current_dir().ok();
291 let path_for_matching = normalize_match_path(file_path, self.canonical_project_root(), cwd.as_deref());
292
293 let cache = self
294 .per_file_flavor_cache
295 .get_or_init(|| PerFileFlavorCache::new(&self.per_file_flavor));
296
297 let absolute = absolute_match_path(file_path, cache.has_absolute);
301 for (matcher, flavor) in &cache.matchers {
302 if matcher.is_match(path_for_matching.as_ref())
303 || absolute.as_ref().is_some_and(|abs| matcher.is_match(abs))
304 {
305 return *flavor;
306 }
307 }
308
309 self.resolve_flavor_fallback(file_path)
311 }
312
313 fn resolve_flavor_fallback(&self, file_path: &Path) -> MarkdownFlavor {
315 if self.global.flavor != MarkdownFlavor::Standard {
317 return self.global.flavor;
318 }
319 MarkdownFlavor::from_path(file_path)
321 }
322
323 pub fn canonicalize_rule_lists(&mut self) {
339 use super::registry::canonicalize_rule_list_in_place;
340 self.global.canonicalize_rule_lists();
341 for rules in self.per_file_ignores.values_mut() {
342 canonicalize_rule_list_in_place(rules);
343 }
344 }
345
346 pub fn merge_with_inline_config(&self, inline_config: &crate::inline_config::InlineConfig) -> Self {
354 let overrides = inline_config.get_all_rule_configs();
355 if overrides.is_empty() {
356 return self.clone();
357 }
358
359 let mut merged = self.clone();
360
361 for (rule_name, json_override) in overrides {
362 let rule_config = merged.rules.entry(rule_name.clone()).or_default();
364
365 if let Some(obj) = json_override.as_object() {
367 for (key, value) in obj {
368 let normalized_key = key.replace('_', "-");
370
371 if let Some(toml_value) = json_to_toml(value) {
373 rule_config.values.insert(normalized_key, toml_value);
374 }
375 }
376 }
377 }
378
379 merged
380 }
381}
382
383pub(super) fn normalize_match_path<'a>(
406 file_path: &'a Path,
407 canonical_project_root: Option<&Path>,
408 cwd: Option<&Path>,
409) -> std::borrow::Cow<'a, Path> {
410 use std::borrow::Cow;
411
412 if file_path.is_relative() {
413 return Cow::Borrowed(file_path);
414 }
415
416 let Ok(canonical_file) = file_path.canonicalize() else {
417 log::debug!(
418 "normalize_match_path: canonicalize failed for {}; returning raw path. \
419 Per-file glob patterns may not match (file may not yet exist on disk).",
420 file_path.display()
421 );
422 return Cow::Borrowed(file_path);
423 };
424
425 if let Some(root) = canonical_project_root
426 && let Ok(rel) = canonical_file.strip_prefix(root)
427 {
428 return Cow::Owned(rel.to_path_buf());
429 }
430
431 if let Some(working_dir) = cwd
432 && let Ok(canonical_cwd) = working_dir.canonicalize()
433 && let Ok(rel) = canonical_file.strip_prefix(&canonical_cwd)
434 {
435 return Cow::Owned(rel.to_path_buf());
436 }
437
438 static SILENT_FALLBACK_WARNED: OnceLock<()> = OnceLock::new();
442 log::log!(
443 first_call_warn_else_debug(&SILENT_FALLBACK_WARNED),
444 "{}",
445 format_silent_fallback_message(file_path, canonical_project_root, cwd),
446 );
447 Cow::Borrowed(file_path)
448}
449
450pub(super) fn first_call_warn_else_debug(latch: &OnceLock<()>) -> log::Level {
458 if latch.set(()).is_ok() {
459 log::Level::Warn
460 } else {
461 log::Level::Debug
462 }
463}
464
465pub(super) fn format_silent_fallback_message(
470 file_path: &Path,
471 canonical_project_root: Option<&Path>,
472 cwd: Option<&Path>,
473) -> String {
474 format!(
475 "Per-file glob patterns will not match {}: file is outside project_root ({}) and cwd ({})",
476 file_path.display(),
477 DisplayPathOrUnset(canonical_project_root),
478 DisplayPathOrUnset(cwd),
479 )
480}
481
482struct DisplayPathOrUnset<'a>(Option<&'a Path>);
488
489impl std::fmt::Display for DisplayPathOrUnset<'_> {
490 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
491 match self.0 {
492 Some(path) => std::fmt::Display::fmt(&path.display(), f),
493 None => f.write_str("<unset>"),
494 }
495 }
496}
497
498pub(super) fn json_to_toml(json: &serde_json::Value) -> Option<toml::Value> {
500 match json {
501 serde_json::Value::Null => None,
502 serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
503 serde_json::Value::Number(n) => n
504 .as_i64()
505 .map(toml::Value::Integer)
506 .or_else(|| n.as_f64().map(toml::Value::Float)),
507 serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
508 serde_json::Value::Array(arr) => {
509 let toml_arr: Vec<toml::Value> = arr.iter().filter_map(json_to_toml).collect();
510 Some(toml::Value::Array(toml_arr))
511 }
512 serde_json::Value::Object(obj) => {
513 let mut table = toml::map::Map::new();
514 for (k, v) in obj {
515 if let Some(tv) = json_to_toml(v) {
516 table.insert(k.clone(), tv);
517 }
518 }
519 Some(toml::Value::Table(table))
520 }
521 }
522}
523
524impl PerFileIgnoreCache {
525 fn new(per_file_ignores: &BTreeMap<String, Vec<String>>) -> Self {
526 let mut builder = GlobSetBuilder::new();
527 let mut rules = Vec::new();
528
529 let mut has_absolute = false;
530 for (pattern, rules_list) in per_file_ignores {
531 let pattern = crate::discovery::expand_home_prefix(pattern);
532 has_absolute |= crate::discovery::is_absolute_pattern(&pattern);
533 if let Ok(glob) = Glob::new(&pattern) {
534 builder.add(glob);
535 rules.push(
541 rules_list
542 .iter()
543 .map(|rule| super::registry::resolve_rule_name(rule))
544 .collect(),
545 );
546 } else {
547 log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
548 }
549 }
550
551 let globset = builder.build().unwrap_or_else(|e| {
552 log::error!("Failed to build globset for per-file-ignores: {e}");
553 GlobSetBuilder::new().build().unwrap()
554 });
555
556 Self {
557 globset,
558 rules,
559 has_absolute,
560 }
561 }
562}
563
564impl PerFileFlavorCache {
565 fn new(per_file_flavor: &IndexMap<String, MarkdownFlavor>) -> Self {
566 let mut matchers = Vec::new();
567
568 let mut has_absolute = false;
569 for (pattern, flavor) in per_file_flavor {
570 let pattern = crate::discovery::expand_home_prefix(pattern);
571 has_absolute |= crate::discovery::is_absolute_pattern(&pattern);
572 if let Ok(glob) = GlobBuilder::new(&pattern).literal_separator(true).build() {
573 matchers.push((glob.compile_matcher(), *flavor));
574 } else {
575 log::warn!("Invalid glob pattern in per-file-flavor: {pattern}");
576 }
577 }
578
579 Self { matchers, has_absolute }
580 }
581}
582
583#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
585#[serde(default, rename_all = "kebab-case")]
586pub struct GlobalConfig {
587 #[serde(default)]
589 pub enable: Vec<String>,
590
591 #[serde(default)]
593 pub disable: Vec<String>,
594
595 #[serde(default)]
599 pub exclude: Vec<String>,
600
601 #[serde(default)]
605 pub include: Vec<String>,
606
607 #[serde(default = "default_respect_gitignore", alias = "respect_gitignore")]
609 pub respect_gitignore: bool,
610
611 #[serde(default, alias = "line_length")]
613 pub line_length: LineLength,
614
615 #[serde(skip_serializing_if = "Option::is_none", alias = "output_format")]
617 pub output_format: Option<String>,
618
619 #[serde(default)]
622 pub fixable: Vec<String>,
623
624 #[serde(default)]
627 pub unfixable: Vec<String>,
628
629 #[serde(default)]
632 pub flavor: MarkdownFlavor,
633
634 #[serde(default, alias = "force_exclude")]
639 #[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
640 pub force_exclude: bool,
641
642 #[serde(default, alias = "cache_dir", skip_serializing_if = "Option::is_none")]
647 pub cache_dir: Option<String>,
648
649 #[serde(default = "default_true")]
652 pub cache: bool,
653
654 #[serde(default, alias = "extend_enable")]
656 pub extend_enable: Vec<String>,
657
658 #[serde(default, alias = "extend_disable")]
660 pub extend_disable: Vec<String>,
661
662 #[serde(default)]
667 pub editorconfig: bool,
668
669 #[serde(skip)]
673 pub enable_is_explicit: bool,
674}
675
676fn default_respect_gitignore() -> bool {
677 true
678}
679
680fn default_true() -> bool {
681 true
682}
683
684impl Default for GlobalConfig {
686 #[allow(deprecated)]
687 fn default() -> Self {
688 Self {
689 enable: Vec::new(),
690 disable: Vec::new(),
691 exclude: Vec::new(),
692 include: Vec::new(),
693 respect_gitignore: true,
694 line_length: LineLength::default(),
695 output_format: None,
696 fixable: Vec::new(),
697 unfixable: Vec::new(),
698 flavor: MarkdownFlavor::default(),
699 force_exclude: false,
700 cache_dir: None,
701 cache: true,
702 extend_enable: Vec::new(),
703 extend_disable: Vec::new(),
704 editorconfig: false,
705 enable_is_explicit: false,
706 }
707 }
708}
709
710impl GlobalConfig {
711 pub fn canonicalize_rule_lists(&mut self) {
725 use super::registry::canonicalize_rule_list_in_place;
726 canonicalize_rule_list_in_place(&mut self.enable);
727 canonicalize_rule_list_in_place(&mut self.disable);
728 canonicalize_rule_list_in_place(&mut self.extend_enable);
729 canonicalize_rule_list_in_place(&mut self.extend_disable);
730 canonicalize_rule_list_in_place(&mut self.fixable);
731 canonicalize_rule_list_in_place(&mut self.unfixable);
732 }
733}
734
735pub const RUMDL_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"];
748
749pub const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
750 ".markdownlint-cli2.jsonc",
751 ".markdownlint-cli2.yaml",
752 ".markdownlint-cli2.yml",
753 ".markdownlint.json",
754 ".markdownlint.jsonc",
755 ".markdownlint.yaml",
756 ".markdownlint.yml",
757 "markdownlint.json",
758 "markdownlint.jsonc",
759 "markdownlint.yaml",
760 "markdownlint.yml",
761];
762
763pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
765 create_preset_config("default", path)
766}
767
768pub fn create_preset_config(preset: &str, path: &str) -> Result<(), ConfigError> {
770 if Path::new(path).exists() {
771 return Err(ConfigError::FileExists { path: path.to_string() });
772 }
773
774 let config_content = match preset {
775 "default" => generate_default_preset(),
776 "google" => generate_google_preset(),
777 "relaxed" => generate_relaxed_preset(),
778 _ => {
779 return Err(ConfigError::UnknownPreset {
780 name: preset.to_string(),
781 });
782 }
783 };
784
785 match fs::write(path, config_content) {
786 Ok(_) => Ok(()),
787 Err(err) => Err(ConfigError::IoError {
788 source: err,
789 path: path.to_string(),
790 }),
791 }
792}
793
794fn generate_default_preset() -> String {
797 r#"# rumdl configuration file
798
799# Inherit settings from another config file (relative to this file's directory)
800# extends = "../base.rumdl.toml"
801
802# Global configuration options
803[global]
804# List of rules to disable (uncomment and modify as needed)
805# disable = ["MD013", "MD033"]
806
807# List of rules to enable exclusively (replaces defaults; only these rules will run)
808# enable = ["MD001", "MD003", "MD004"]
809
810# Additional rules to enable on top of defaults (additive, does not replace)
811# Use this to activate opt-in rules like MD060, MD063, MD072, MD073, MD074
812# extend-enable = ["MD060", "MD063"]
813
814# Additional rules to disable on top of the disable list (additive)
815# extend-disable = ["MD041"]
816
817# List of file/directory patterns to include for linting (if provided, only these will be linted)
818# include = [
819# "docs/*.md",
820# "src/**/*.md",
821# "README.md"
822# ]
823
824# List of file/directory patterns to exclude from linting
825exclude = [
826 # Common directories to exclude
827 ".git",
828 ".github",
829 "node_modules",
830 "vendor",
831 "dist",
832 "build",
833
834 # Specific files or patterns
835 "CHANGELOG.md",
836 "LICENSE.md",
837]
838
839# Respect .gitignore files when scanning directories (default: true)
840respect-gitignore = true
841
842# Markdown flavor/dialect (uncomment to enable)
843# Options: standard (default), gfm, commonmark, mkdocs, mdx, pandoc, quarto, obsidian, kramdown, azure_devops, myst
844# flavor = "mkdocs"
845
846# Rule-specific configurations (uncomment and modify as needed)
847
848# [MD003]
849# style = "atx" # Heading style (atx, atx_closed, setext)
850
851# [MD004]
852# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
853
854# [MD007]
855# indent = 4 # Unordered list indentation
856
857# [MD013]
858# line-length = 100 # Line length
859# code-blocks = false # Exclude code blocks from line length check
860# tables = false # Exclude tables from line length check
861# headings = true # Include headings in line length check
862
863# [MD044]
864# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
865# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
866"#
867 .to_string()
868}
869
870fn generate_google_preset() -> String {
873 r#"# rumdl configuration - Google developer documentation style
874# Based on https://google.github.io/styleguide/docguide/style.html
875
876[global]
877exclude = [
878 ".git",
879 ".github",
880 "node_modules",
881 "vendor",
882 "dist",
883 "build",
884 "CHANGELOG.md",
885 "LICENSE.md",
886]
887respect-gitignore = true
888
889# ATX-style headings required
890[MD003]
891style = "atx"
892
893# Unordered list style: dash
894[MD004]
895style = "dash"
896
897# 4-space indent for nested lists
898[MD007]
899indent = 4
900
901# Strict mode: no trailing spaces allowed (Google uses backslash for line breaks)
902[MD009]
903strict = true
904
905# 80-character line length
906[MD013]
907line-length = 80
908code-blocks = false
909tables = false
910
911# No trailing punctuation in headings
912[MD026]
913punctuation = ".,;:!。,;:!"
914
915# Fenced code blocks only (no indented code blocks)
916[MD046]
917style = "fenced"
918
919# Emphasis with underscores
920[MD049]
921style = "underscore"
922
923# Strong with asterisks
924[MD050]
925style = "asterisk"
926"#
927 .to_string()
928}
929
930fn generate_relaxed_preset() -> String {
933 r#"# rumdl configuration - Relaxed preset
934# Lenient settings for existing projects adopting rumdl incrementally.
935# Minimizes initial warnings while still catching important issues.
936
937[global]
938exclude = [
939 ".git",
940 ".github",
941 "node_modules",
942 "vendor",
943 "dist",
944 "build",
945 "CHANGELOG.md",
946 "LICENSE.md",
947]
948respect-gitignore = true
949
950# Disable rules that produce the most noise on existing projects
951disable = [
952 "MD013", # Line length - most existing files exceed 80 chars
953 "MD033", # Inline HTML - commonly used in real-world markdown
954 "MD041", # First line heading - not all files need it
955]
956
957# Consistent heading style (any style, just be consistent)
958[MD003]
959style = "consistent"
960
961# Consistent list style
962[MD004]
963style = "consistent"
964
965# Consistent emphasis style
966[MD049]
967style = "consistent"
968
969# Consistent strong style
970[MD050]
971style = "consistent"
972"#
973 .to_string()
974}
975
976#[derive(Debug, thiserror::Error)]
978pub enum ConfigError {
979 #[error("Failed to read config file at {path}: {source}")]
981 IoError { source: io::Error, path: String },
982
983 #[error("Failed to parse config: {0}")]
985 ParseError(String),
986
987 #[error("Configuration file already exists at {path}")]
989 FileExists { path: String },
990
991 #[error("Circular extends reference: {path} already in chain {chain:?}")]
993 CircularExtends { path: String, chain: Vec<String> },
994
995 #[error("Extends chain exceeds maximum depth of {max_depth} at {path}")]
997 ExtendsDepthExceeded { path: String, max_depth: usize },
998
999 #[error("extends target not found: {path} (referenced from {from})")]
1001 ExtendsNotFound { path: String, from: String },
1002
1003 #[error("extends path references undefined environment variable ${var} (referenced from {from})")]
1005 ExtendsUndefinedVar { var: String, from: String },
1006
1007 #[error("Unknown preset: {name}. Valid presets: default, google, relaxed")]
1009 UnknownPreset { name: String },
1010}
1011
1012#[derive(Debug, thiserror::Error)]
1020pub enum DiscoveredConfigError {
1021 #[error(transparent)]
1023 ProjectConfig(ConfigError),
1024
1025 #[error(transparent)]
1028 UserConfig(ConfigError),
1029}
1030
1031impl From<DiscoveredConfigError> for ConfigError {
1032 fn from(error: DiscoveredConfigError) -> Self {
1033 match error {
1034 DiscoveredConfigError::ProjectConfig(error) | DiscoveredConfigError::UserConfig(error) => error,
1035 }
1036 }
1037}
1038
1039pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
1043 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_config = config.rules.get(&norm_rule_name)?;
1046
1047 let key_variants = [
1049 key.to_string(), normalize_key(key), key.replace('-', "_"), key.replace('_', "-"), ];
1054
1055 for variant in &key_variants {
1057 if let Some(value) = rule_config.values.get(variant)
1058 && let Ok(result) = T::deserialize(value.clone())
1059 {
1060 return Some(result);
1061 }
1062 }
1063
1064 None
1065}
1066
1067pub fn generate_pyproject_preset_config(preset: &str) -> Result<String, ConfigError> {
1070 match preset {
1071 "default" => Ok(generate_pyproject_config()),
1072 other => {
1073 let rumdl_config = match other {
1074 "google" => generate_google_preset(),
1075 "relaxed" => generate_relaxed_preset(),
1076 _ => {
1077 return Err(ConfigError::UnknownPreset {
1078 name: other.to_string(),
1079 });
1080 }
1081 };
1082 Ok(convert_rumdl_to_pyproject(&rumdl_config))
1083 }
1084 }
1085}
1086
1087fn convert_rumdl_to_pyproject(rumdl_config: &str) -> String {
1090 let mut output = String::with_capacity(rumdl_config.len() + 128);
1091 for line in rumdl_config.lines() {
1092 let trimmed = line.trim();
1093 if trimmed.starts_with('[') && trimmed.ends_with(']') && !trimmed.starts_with("# [") {
1094 let section = &trimmed[1..trimmed.len() - 1];
1095 if section == "global" {
1096 output.push_str("[tool.rumdl]");
1097 } else {
1098 output.push_str(&format!("[tool.rumdl.{section}]"));
1099 }
1100 } else {
1101 output.push_str(line);
1102 }
1103 output.push('\n');
1104 }
1105 output
1106}
1107
1108pub fn generate_pyproject_config() -> String {
1110 let config_content = r#"
1111[tool.rumdl]
1112# Global configuration options
1113line-length = 100
1114disable = []
1115# extend-enable = ["MD060"] # Add opt-in rules (additive, keeps defaults)
1116# extend-disable = [] # Additional rules to disable (additive)
1117exclude = [
1118 # Common directories to exclude
1119 ".git",
1120 ".github",
1121 "node_modules",
1122 "vendor",
1123 "dist",
1124 "build",
1125]
1126respect-gitignore = true
1127
1128# Rule-specific configurations (uncomment and modify as needed)
1129
1130# [tool.rumdl.MD003]
1131# style = "atx" # Heading style (atx, atx_closed, setext)
1132
1133# [tool.rumdl.MD004]
1134# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
1135
1136# [tool.rumdl.MD007]
1137# indent = 4 # Unordered list indentation
1138
1139# [tool.rumdl.MD013]
1140# line-length = 100 # Line length
1141# code-blocks = false # Exclude code blocks from line length check
1142# tables = false # Exclude tables from line length check
1143# headings = true # Include headings in line length check
1144
1145# [tool.rumdl.MD044]
1146# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
1147# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
1148"#;
1149
1150 config_content.to_string()
1151}