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(skip)]
666 pub enable_is_explicit: bool,
667}
668
669fn default_respect_gitignore() -> bool {
670 true
671}
672
673fn default_true() -> bool {
674 true
675}
676
677impl Default for GlobalConfig {
679 #[allow(deprecated)]
680 fn default() -> Self {
681 Self {
682 enable: Vec::new(),
683 disable: Vec::new(),
684 exclude: Vec::new(),
685 include: Vec::new(),
686 respect_gitignore: true,
687 line_length: LineLength::default(),
688 output_format: None,
689 fixable: Vec::new(),
690 unfixable: Vec::new(),
691 flavor: MarkdownFlavor::default(),
692 force_exclude: false,
693 cache_dir: None,
694 cache: true,
695 extend_enable: Vec::new(),
696 extend_disable: Vec::new(),
697 enable_is_explicit: false,
698 }
699 }
700}
701
702impl GlobalConfig {
703 pub fn canonicalize_rule_lists(&mut self) {
717 use super::registry::canonicalize_rule_list_in_place;
718 canonicalize_rule_list_in_place(&mut self.enable);
719 canonicalize_rule_list_in_place(&mut self.disable);
720 canonicalize_rule_list_in_place(&mut self.extend_enable);
721 canonicalize_rule_list_in_place(&mut self.extend_disable);
722 canonicalize_rule_list_in_place(&mut self.fixable);
723 canonicalize_rule_list_in_place(&mut self.unfixable);
724 }
725}
726
727pub const RUMDL_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"];
740
741pub const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
742 ".markdownlint-cli2.jsonc",
743 ".markdownlint-cli2.yaml",
744 ".markdownlint-cli2.yml",
745 ".markdownlint.json",
746 ".markdownlint.jsonc",
747 ".markdownlint.yaml",
748 ".markdownlint.yml",
749 "markdownlint.json",
750 "markdownlint.jsonc",
751 "markdownlint.yaml",
752 "markdownlint.yml",
753];
754
755pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
757 create_preset_config("default", path)
758}
759
760pub fn create_preset_config(preset: &str, path: &str) -> Result<(), ConfigError> {
762 if Path::new(path).exists() {
763 return Err(ConfigError::FileExists { path: path.to_string() });
764 }
765
766 let config_content = match preset {
767 "default" => generate_default_preset(),
768 "google" => generate_google_preset(),
769 "relaxed" => generate_relaxed_preset(),
770 _ => {
771 return Err(ConfigError::UnknownPreset {
772 name: preset.to_string(),
773 });
774 }
775 };
776
777 match fs::write(path, config_content) {
778 Ok(_) => Ok(()),
779 Err(err) => Err(ConfigError::IoError {
780 source: err,
781 path: path.to_string(),
782 }),
783 }
784}
785
786fn generate_default_preset() -> String {
789 r#"# rumdl configuration file
790
791# Inherit settings from another config file (relative to this file's directory)
792# extends = "../base.rumdl.toml"
793
794# Global configuration options
795[global]
796# List of rules to disable (uncomment and modify as needed)
797# disable = ["MD013", "MD033"]
798
799# List of rules to enable exclusively (replaces defaults; only these rules will run)
800# enable = ["MD001", "MD003", "MD004"]
801
802# Additional rules to enable on top of defaults (additive, does not replace)
803# Use this to activate opt-in rules like MD060, MD063, MD072, MD073, MD074
804# extend-enable = ["MD060", "MD063"]
805
806# Additional rules to disable on top of the disable list (additive)
807# extend-disable = ["MD041"]
808
809# List of file/directory patterns to include for linting (if provided, only these will be linted)
810# include = [
811# "docs/*.md",
812# "src/**/*.md",
813# "README.md"
814# ]
815
816# List of file/directory patterns to exclude from linting
817exclude = [
818 # Common directories to exclude
819 ".git",
820 ".github",
821 "node_modules",
822 "vendor",
823 "dist",
824 "build",
825
826 # Specific files or patterns
827 "CHANGELOG.md",
828 "LICENSE.md",
829]
830
831# Respect .gitignore files when scanning directories (default: true)
832respect-gitignore = true
833
834# Markdown flavor/dialect (uncomment to enable)
835# Options: standard (default), gfm, commonmark, mkdocs, mdx, pandoc, quarto, obsidian, kramdown, azure_devops, myst
836# flavor = "mkdocs"
837
838# Rule-specific configurations (uncomment and modify as needed)
839
840# [MD003]
841# style = "atx" # Heading style (atx, atx_closed, setext)
842
843# [MD004]
844# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
845
846# [MD007]
847# indent = 4 # Unordered list indentation
848
849# [MD013]
850# line-length = 100 # Line length
851# code-blocks = false # Exclude code blocks from line length check
852# tables = false # Exclude tables from line length check
853# headings = true # Include headings in line length check
854
855# [MD044]
856# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
857# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
858"#
859 .to_string()
860}
861
862fn generate_google_preset() -> String {
865 r#"# rumdl configuration - Google developer documentation style
866# Based on https://google.github.io/styleguide/docguide/style.html
867
868[global]
869exclude = [
870 ".git",
871 ".github",
872 "node_modules",
873 "vendor",
874 "dist",
875 "build",
876 "CHANGELOG.md",
877 "LICENSE.md",
878]
879respect-gitignore = true
880
881# ATX-style headings required
882[MD003]
883style = "atx"
884
885# Unordered list style: dash
886[MD004]
887style = "dash"
888
889# 4-space indent for nested lists
890[MD007]
891indent = 4
892
893# Strict mode: no trailing spaces allowed (Google uses backslash for line breaks)
894[MD009]
895strict = true
896
897# 80-character line length
898[MD013]
899line-length = 80
900code-blocks = false
901tables = false
902
903# No trailing punctuation in headings
904[MD026]
905punctuation = ".,;:!。,;:!"
906
907# Fenced code blocks only (no indented code blocks)
908[MD046]
909style = "fenced"
910
911# Emphasis with underscores
912[MD049]
913style = "underscore"
914
915# Strong with asterisks
916[MD050]
917style = "asterisk"
918"#
919 .to_string()
920}
921
922fn generate_relaxed_preset() -> String {
925 r#"# rumdl configuration - Relaxed preset
926# Lenient settings for existing projects adopting rumdl incrementally.
927# Minimizes initial warnings while still catching important issues.
928
929[global]
930exclude = [
931 ".git",
932 ".github",
933 "node_modules",
934 "vendor",
935 "dist",
936 "build",
937 "CHANGELOG.md",
938 "LICENSE.md",
939]
940respect-gitignore = true
941
942# Disable rules that produce the most noise on existing projects
943disable = [
944 "MD013", # Line length - most existing files exceed 80 chars
945 "MD033", # Inline HTML - commonly used in real-world markdown
946 "MD041", # First line heading - not all files need it
947]
948
949# Consistent heading style (any style, just be consistent)
950[MD003]
951style = "consistent"
952
953# Consistent list style
954[MD004]
955style = "consistent"
956
957# Consistent emphasis style
958[MD049]
959style = "consistent"
960
961# Consistent strong style
962[MD050]
963style = "consistent"
964"#
965 .to_string()
966}
967
968#[derive(Debug, thiserror::Error)]
970pub enum ConfigError {
971 #[error("Failed to read config file at {path}: {source}")]
973 IoError { source: io::Error, path: String },
974
975 #[error("Failed to parse config: {0}")]
977 ParseError(String),
978
979 #[error("Configuration file already exists at {path}")]
981 FileExists { path: String },
982
983 #[error("Circular extends reference: {path} already in chain {chain:?}")]
985 CircularExtends { path: String, chain: Vec<String> },
986
987 #[error("Extends chain exceeds maximum depth of {max_depth} at {path}")]
989 ExtendsDepthExceeded { path: String, max_depth: usize },
990
991 #[error("extends target not found: {path} (referenced from {from})")]
993 ExtendsNotFound { path: String, from: String },
994
995 #[error("extends path references undefined environment variable ${var} (referenced from {from})")]
997 ExtendsUndefinedVar { var: String, from: String },
998
999 #[error("Unknown preset: {name}. Valid presets: default, google, relaxed")]
1001 UnknownPreset { name: String },
1002}
1003
1004pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
1008 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_config = config.rules.get(&norm_rule_name)?;
1011
1012 let key_variants = [
1014 key.to_string(), normalize_key(key), key.replace('-', "_"), key.replace('_', "-"), ];
1019
1020 for variant in &key_variants {
1022 if let Some(value) = rule_config.values.get(variant)
1023 && let Ok(result) = T::deserialize(value.clone())
1024 {
1025 return Some(result);
1026 }
1027 }
1028
1029 None
1030}
1031
1032pub fn generate_pyproject_preset_config(preset: &str) -> Result<String, ConfigError> {
1035 match preset {
1036 "default" => Ok(generate_pyproject_config()),
1037 other => {
1038 let rumdl_config = match other {
1039 "google" => generate_google_preset(),
1040 "relaxed" => generate_relaxed_preset(),
1041 _ => {
1042 return Err(ConfigError::UnknownPreset {
1043 name: other.to_string(),
1044 });
1045 }
1046 };
1047 Ok(convert_rumdl_to_pyproject(&rumdl_config))
1048 }
1049 }
1050}
1051
1052fn convert_rumdl_to_pyproject(rumdl_config: &str) -> String {
1055 let mut output = String::with_capacity(rumdl_config.len() + 128);
1056 for line in rumdl_config.lines() {
1057 let trimmed = line.trim();
1058 if trimmed.starts_with('[') && trimmed.ends_with(']') && !trimmed.starts_with("# [") {
1059 let section = &trimmed[1..trimmed.len() - 1];
1060 if section == "global" {
1061 output.push_str("[tool.rumdl]");
1062 } else {
1063 output.push_str(&format!("[tool.rumdl.{section}]"));
1064 }
1065 } else {
1066 output.push_str(line);
1067 }
1068 output.push('\n');
1069 }
1070 output
1071}
1072
1073pub fn generate_pyproject_config() -> String {
1075 let config_content = r#"
1076[tool.rumdl]
1077# Global configuration options
1078line-length = 100
1079disable = []
1080# extend-enable = ["MD060"] # Add opt-in rules (additive, keeps defaults)
1081# extend-disable = [] # Additional rules to disable (additive)
1082exclude = [
1083 # Common directories to exclude
1084 ".git",
1085 ".github",
1086 "node_modules",
1087 "vendor",
1088 "dist",
1089 "build",
1090]
1091respect-gitignore = true
1092
1093# Rule-specific configurations (uncomment and modify as needed)
1094
1095# [tool.rumdl.MD003]
1096# style = "atx" # Heading style (atx, atx_closed, setext)
1097
1098# [tool.rumdl.MD004]
1099# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
1100
1101# [tool.rumdl.MD007]
1102# indent = 4 # Unordered list indentation
1103
1104# [tool.rumdl.MD013]
1105# line-length = 100 # Line length
1106# code-blocks = false # Exclude code blocks from line length check
1107# tables = false # Exclude tables from line length check
1108# headings = true # Include headings in line length check
1109
1110# [tool.rumdl.MD044]
1111# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
1112# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
1113"#;
1114
1115 config_content.to_string()
1116}