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")]
44 pub extends: Option<String>,
45
46 #[serde(default)]
48 pub global: GlobalConfig,
49
50 #[serde(default, rename = "per-file-ignores")]
53 pub per_file_ignores: BTreeMap<String, Vec<String>>,
54
55 #[serde(default, rename = "per-file-flavor")]
59 #[schemars(with = "BTreeMap<String, MarkdownFlavor>")]
60 pub per_file_flavor: IndexMap<String, MarkdownFlavor>,
61
62 #[serde(default, rename = "code-block-tools")]
65 pub code_block_tools: crate::code_block_tools::CodeBlockToolsConfig,
66
67 #[serde(flatten)]
78 pub rules: BTreeMap<String, RuleConfig>,
79
80 #[serde(skip)]
82 pub project_root: Option<std::path::PathBuf>,
83
84 #[serde(skip)]
85 #[schemars(skip)]
86 pub(super) per_file_ignores_cache: Arc<OnceLock<PerFileIgnoreCache>>,
87
88 #[serde(skip)]
89 #[schemars(skip)]
90 pub(super) per_file_flavor_cache: Arc<OnceLock<PerFileFlavorCache>>,
91
92 #[serde(skip)]
118 #[schemars(skip)]
119 pub(super) canonical_project_root_cache: Arc<OnceLock<Option<PathBuf>>>,
120}
121
122impl PartialEq for Config {
123 fn eq(&self, other: &Self) -> bool {
124 self.global == other.global
125 && self.per_file_ignores == other.per_file_ignores
126 && self.per_file_flavor == other.per_file_flavor
127 && self.code_block_tools == other.code_block_tools
128 && self.rules == other.rules
129 && self.project_root == other.project_root
130 }
131}
132
133#[derive(Debug)]
134pub(super) struct PerFileIgnoreCache {
135 globset: GlobSet,
136 rules: Vec<Vec<String>>,
137}
138
139#[derive(Debug)]
140pub(super) struct PerFileFlavorCache {
141 matchers: Vec<(GlobMatcher, MarkdownFlavor)>,
142}
143
144impl Config {
145 pub fn is_mkdocs_flavor(&self) -> bool {
147 self.global.flavor == MarkdownFlavor::MkDocs
148 }
149
150 pub fn markdown_flavor(&self) -> MarkdownFlavor {
156 self.global.flavor
157 }
158
159 pub fn is_mkdocs_project(&self) -> bool {
161 self.is_mkdocs_flavor()
162 }
163
164 pub fn apply_per_rule_enabled(&mut self) {
175 let mut to_enable: Vec<String> = Vec::new();
176 let mut to_disable: Vec<String> = Vec::new();
177
178 for (name, cfg) in &self.rules {
179 match cfg.values.get("enabled") {
180 Some(toml::Value::Boolean(true)) => {
181 to_enable.push(name.clone());
182 }
183 Some(toml::Value::Boolean(false)) => {
184 to_disable.push(name.clone());
185 }
186 _ => {}
187 }
188 }
189
190 for name in to_enable {
191 if !self.global.extend_enable.contains(&name) {
192 self.global.extend_enable.push(name.clone());
193 }
194 self.global.disable.retain(|n| n != &name);
195 self.global.extend_disable.retain(|n| n != &name);
196 }
197
198 for name in to_disable {
199 if !self.global.disable.contains(&name) {
200 self.global.disable.push(name.clone());
201 }
202 self.global.extend_enable.retain(|n| n != &name);
203 }
204 }
205
206 pub fn get_rule_severity(&self, rule_name: &str) -> Option<crate::rule::Severity> {
208 self.rules.get(rule_name).and_then(|r| r.severity)
209 }
210
211 pub(super) fn canonical_project_root(&self) -> Option<&Path> {
218 self.canonical_project_root_cache
219 .get_or_init(|| self.project_root.as_deref().and_then(|p| p.canonicalize().ok()))
220 .as_deref()
221 }
222
223 pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
226 let mut ignored_rules = HashSet::new();
227
228 if self.per_file_ignores.is_empty() {
229 return ignored_rules;
230 }
231
232 let cwd = std::env::current_dir().ok();
233 let path_for_matching = normalize_match_path(file_path, self.canonical_project_root(), cwd.as_deref());
234
235 let cache = self
236 .per_file_ignores_cache
237 .get_or_init(|| PerFileIgnoreCache::new(&self.per_file_ignores));
238
239 for match_idx in cache.globset.matches(path_for_matching.as_ref()) {
241 if let Some(rules) = cache.rules.get(match_idx) {
242 for rule in rules {
243 ignored_rules.insert(rule.clone());
245 }
246 }
247 }
248
249 ignored_rules
250 }
251
252 pub fn get_flavor_for_file(&self, file_path: &Path) -> MarkdownFlavor {
256 if self.per_file_flavor.is_empty() {
258 return self.resolve_flavor_fallback(file_path);
259 }
260
261 let cwd = std::env::current_dir().ok();
262 let path_for_matching = normalize_match_path(file_path, self.canonical_project_root(), cwd.as_deref());
263
264 let cache = self
265 .per_file_flavor_cache
266 .get_or_init(|| PerFileFlavorCache::new(&self.per_file_flavor));
267
268 for (matcher, flavor) in &cache.matchers {
270 if matcher.is_match(path_for_matching.as_ref()) {
271 return *flavor;
272 }
273 }
274
275 self.resolve_flavor_fallback(file_path)
277 }
278
279 fn resolve_flavor_fallback(&self, file_path: &Path) -> MarkdownFlavor {
281 if self.global.flavor != MarkdownFlavor::Standard {
283 return self.global.flavor;
284 }
285 MarkdownFlavor::from_path(file_path)
287 }
288
289 pub fn canonicalize_rule_lists(&mut self) {
305 use super::registry::canonicalize_rule_list_in_place;
306 self.global.canonicalize_rule_lists();
307 for rules in self.per_file_ignores.values_mut() {
308 canonicalize_rule_list_in_place(rules);
309 }
310 }
311
312 pub fn merge_with_inline_config(&self, inline_config: &crate::inline_config::InlineConfig) -> Self {
320 let overrides = inline_config.get_all_rule_configs();
321 if overrides.is_empty() {
322 return self.clone();
323 }
324
325 let mut merged = self.clone();
326
327 for (rule_name, json_override) in overrides {
328 let rule_config = merged.rules.entry(rule_name.clone()).or_default();
330
331 if let Some(obj) = json_override.as_object() {
333 for (key, value) in obj {
334 let normalized_key = key.replace('_', "-");
336
337 if let Some(toml_value) = json_to_toml(value) {
339 rule_config.values.insert(normalized_key, toml_value);
340 }
341 }
342 }
343 }
344
345 merged
346 }
347}
348
349pub(super) fn normalize_match_path<'a>(
372 file_path: &'a Path,
373 canonical_project_root: Option<&Path>,
374 cwd: Option<&Path>,
375) -> std::borrow::Cow<'a, Path> {
376 use std::borrow::Cow;
377
378 if file_path.is_relative() {
379 return Cow::Borrowed(file_path);
380 }
381
382 let Ok(canonical_file) = file_path.canonicalize() else {
383 log::debug!(
384 "normalize_match_path: canonicalize failed for {}; returning raw path. \
385 Per-file glob patterns may not match (file may not yet exist on disk).",
386 file_path.display()
387 );
388 return Cow::Borrowed(file_path);
389 };
390
391 if let Some(root) = canonical_project_root
392 && let Ok(rel) = canonical_file.strip_prefix(root)
393 {
394 return Cow::Owned(rel.to_path_buf());
395 }
396
397 if let Some(working_dir) = cwd
398 && let Ok(canonical_cwd) = working_dir.canonicalize()
399 && let Ok(rel) = canonical_file.strip_prefix(&canonical_cwd)
400 {
401 return Cow::Owned(rel.to_path_buf());
402 }
403
404 static SILENT_FALLBACK_WARNED: OnceLock<()> = OnceLock::new();
408 log::log!(
409 first_call_warn_else_debug(&SILENT_FALLBACK_WARNED),
410 "{}",
411 format_silent_fallback_message(file_path, canonical_project_root, cwd),
412 );
413 Cow::Borrowed(file_path)
414}
415
416pub(super) fn first_call_warn_else_debug(latch: &OnceLock<()>) -> log::Level {
424 if latch.set(()).is_ok() {
425 log::Level::Warn
426 } else {
427 log::Level::Debug
428 }
429}
430
431pub(super) fn format_silent_fallback_message(
436 file_path: &Path,
437 canonical_project_root: Option<&Path>,
438 cwd: Option<&Path>,
439) -> String {
440 format!(
441 "Per-file glob patterns will not match {}: file is outside project_root ({}) and cwd ({})",
442 file_path.display(),
443 DisplayPathOrUnset(canonical_project_root),
444 DisplayPathOrUnset(cwd),
445 )
446}
447
448struct DisplayPathOrUnset<'a>(Option<&'a Path>);
454
455impl std::fmt::Display for DisplayPathOrUnset<'_> {
456 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457 match self.0 {
458 Some(path) => std::fmt::Display::fmt(&path.display(), f),
459 None => f.write_str("<unset>"),
460 }
461 }
462}
463
464pub(super) fn json_to_toml(json: &serde_json::Value) -> Option<toml::Value> {
466 match json {
467 serde_json::Value::Null => None,
468 serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
469 serde_json::Value::Number(n) => n
470 .as_i64()
471 .map(toml::Value::Integer)
472 .or_else(|| n.as_f64().map(toml::Value::Float)),
473 serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
474 serde_json::Value::Array(arr) => {
475 let toml_arr: Vec<toml::Value> = arr.iter().filter_map(json_to_toml).collect();
476 Some(toml::Value::Array(toml_arr))
477 }
478 serde_json::Value::Object(obj) => {
479 let mut table = toml::map::Map::new();
480 for (k, v) in obj {
481 if let Some(tv) = json_to_toml(v) {
482 table.insert(k.clone(), tv);
483 }
484 }
485 Some(toml::Value::Table(table))
486 }
487 }
488}
489
490impl PerFileIgnoreCache {
491 fn new(per_file_ignores: &BTreeMap<String, Vec<String>>) -> Self {
492 let mut builder = GlobSetBuilder::new();
493 let mut rules = Vec::new();
494
495 for (pattern, rules_list) in per_file_ignores {
496 if let Ok(glob) = Glob::new(pattern) {
497 builder.add(glob);
498 rules.push(
504 rules_list
505 .iter()
506 .map(|rule| super::registry::resolve_rule_name(rule))
507 .collect(),
508 );
509 } else {
510 log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
511 }
512 }
513
514 let globset = builder.build().unwrap_or_else(|e| {
515 log::error!("Failed to build globset for per-file-ignores: {e}");
516 GlobSetBuilder::new().build().unwrap()
517 });
518
519 Self { globset, rules }
520 }
521}
522
523impl PerFileFlavorCache {
524 fn new(per_file_flavor: &IndexMap<String, MarkdownFlavor>) -> Self {
525 let mut matchers = Vec::new();
526
527 for (pattern, flavor) in per_file_flavor {
528 if let Ok(glob) = GlobBuilder::new(pattern).literal_separator(true).build() {
529 matchers.push((glob.compile_matcher(), *flavor));
530 } else {
531 log::warn!("Invalid glob pattern in per-file-flavor: {pattern}");
532 }
533 }
534
535 Self { matchers }
536 }
537}
538
539#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
541#[serde(default, rename_all = "kebab-case")]
542pub struct GlobalConfig {
543 #[serde(default)]
545 pub enable: Vec<String>,
546
547 #[serde(default)]
549 pub disable: Vec<String>,
550
551 #[serde(default)]
553 pub exclude: Vec<String>,
554
555 #[serde(default)]
557 pub include: Vec<String>,
558
559 #[serde(default = "default_respect_gitignore", alias = "respect_gitignore")]
561 pub respect_gitignore: bool,
562
563 #[serde(default, alias = "line_length")]
565 pub line_length: LineLength,
566
567 #[serde(skip_serializing_if = "Option::is_none", alias = "output_format")]
569 pub output_format: Option<String>,
570
571 #[serde(default)]
574 pub fixable: Vec<String>,
575
576 #[serde(default)]
579 pub unfixable: Vec<String>,
580
581 #[serde(default)]
584 pub flavor: MarkdownFlavor,
585
586 #[serde(default, alias = "force_exclude")]
591 #[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
592 pub force_exclude: bool,
593
594 #[serde(default, alias = "cache_dir", skip_serializing_if = "Option::is_none")]
597 pub cache_dir: Option<String>,
598
599 #[serde(default = "default_true")]
602 pub cache: bool,
603
604 #[serde(default, alias = "extend_enable")]
606 pub extend_enable: Vec<String>,
607
608 #[serde(default, alias = "extend_disable")]
610 pub extend_disable: Vec<String>,
611
612 #[serde(skip)]
616 pub enable_is_explicit: bool,
617}
618
619fn default_respect_gitignore() -> bool {
620 true
621}
622
623fn default_true() -> bool {
624 true
625}
626
627impl Default for GlobalConfig {
629 #[allow(deprecated)]
630 fn default() -> Self {
631 Self {
632 enable: Vec::new(),
633 disable: Vec::new(),
634 exclude: Vec::new(),
635 include: Vec::new(),
636 respect_gitignore: true,
637 line_length: LineLength::default(),
638 output_format: None,
639 fixable: Vec::new(),
640 unfixable: Vec::new(),
641 flavor: MarkdownFlavor::default(),
642 force_exclude: false,
643 cache_dir: None,
644 cache: true,
645 extend_enable: Vec::new(),
646 extend_disable: Vec::new(),
647 enable_is_explicit: false,
648 }
649 }
650}
651
652impl GlobalConfig {
653 pub fn canonicalize_rule_lists(&mut self) {
667 use super::registry::canonicalize_rule_list_in_place;
668 canonicalize_rule_list_in_place(&mut self.enable);
669 canonicalize_rule_list_in_place(&mut self.disable);
670 canonicalize_rule_list_in_place(&mut self.extend_enable);
671 canonicalize_rule_list_in_place(&mut self.extend_disable);
672 canonicalize_rule_list_in_place(&mut self.fixable);
673 canonicalize_rule_list_in_place(&mut self.unfixable);
674 }
675}
676
677pub const RUMDL_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"];
690
691pub const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
692 ".markdownlint-cli2.jsonc",
693 ".markdownlint-cli2.yaml",
694 ".markdownlint-cli2.yml",
695 ".markdownlint.json",
696 ".markdownlint.jsonc",
697 ".markdownlint.yaml",
698 ".markdownlint.yml",
699 "markdownlint.json",
700 "markdownlint.jsonc",
701 "markdownlint.yaml",
702 "markdownlint.yml",
703];
704
705pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
707 create_preset_config("default", path)
708}
709
710pub fn create_preset_config(preset: &str, path: &str) -> Result<(), ConfigError> {
712 if Path::new(path).exists() {
713 return Err(ConfigError::FileExists { path: path.to_string() });
714 }
715
716 let config_content = match preset {
717 "default" => generate_default_preset(),
718 "google" => generate_google_preset(),
719 "relaxed" => generate_relaxed_preset(),
720 _ => {
721 return Err(ConfigError::UnknownPreset {
722 name: preset.to_string(),
723 });
724 }
725 };
726
727 match fs::write(path, config_content) {
728 Ok(_) => Ok(()),
729 Err(err) => Err(ConfigError::IoError {
730 source: err,
731 path: path.to_string(),
732 }),
733 }
734}
735
736fn generate_default_preset() -> String {
739 r#"# rumdl configuration file
740
741# Inherit settings from another config file (relative to this file's directory)
742# extends = "../base.rumdl.toml"
743
744# Global configuration options
745[global]
746# List of rules to disable (uncomment and modify as needed)
747# disable = ["MD013", "MD033"]
748
749# List of rules to enable exclusively (replaces defaults; only these rules will run)
750# enable = ["MD001", "MD003", "MD004"]
751
752# Additional rules to enable on top of defaults (additive, does not replace)
753# Use this to activate opt-in rules like MD060, MD063, MD072, MD073, MD074
754# extend-enable = ["MD060", "MD063"]
755
756# Additional rules to disable on top of the disable list (additive)
757# extend-disable = ["MD041"]
758
759# List of file/directory patterns to include for linting (if provided, only these will be linted)
760# include = [
761# "docs/*.md",
762# "src/**/*.md",
763# "README.md"
764# ]
765
766# List of file/directory patterns to exclude from linting
767exclude = [
768 # Common directories to exclude
769 ".git",
770 ".github",
771 "node_modules",
772 "vendor",
773 "dist",
774 "build",
775
776 # Specific files or patterns
777 "CHANGELOG.md",
778 "LICENSE.md",
779]
780
781# Respect .gitignore files when scanning directories (default: true)
782respect-gitignore = true
783
784# Markdown flavor/dialect (uncomment to enable)
785# Options: standard (default), gfm, commonmark, mkdocs, mdx, quarto
786# flavor = "mkdocs"
787
788# Rule-specific configurations (uncomment and modify as needed)
789
790# [MD003]
791# style = "atx" # Heading style (atx, atx_closed, setext)
792
793# [MD004]
794# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
795
796# [MD007]
797# indent = 4 # Unordered list indentation
798
799# [MD013]
800# line-length = 100 # Line length
801# code-blocks = false # Exclude code blocks from line length check
802# tables = false # Exclude tables from line length check
803# headings = true # Include headings in line length check
804
805# [MD044]
806# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
807# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
808"#
809 .to_string()
810}
811
812fn generate_google_preset() -> String {
815 r#"# rumdl configuration - Google developer documentation style
816# Based on https://google.github.io/styleguide/docguide/style.html
817
818[global]
819exclude = [
820 ".git",
821 ".github",
822 "node_modules",
823 "vendor",
824 "dist",
825 "build",
826 "CHANGELOG.md",
827 "LICENSE.md",
828]
829respect-gitignore = true
830
831# ATX-style headings required
832[MD003]
833style = "atx"
834
835# Unordered list style: dash
836[MD004]
837style = "dash"
838
839# 4-space indent for nested lists
840[MD007]
841indent = 4
842
843# Strict mode: no trailing spaces allowed (Google uses backslash for line breaks)
844[MD009]
845strict = true
846
847# 80-character line length
848[MD013]
849line-length = 80
850code-blocks = false
851tables = false
852
853# No trailing punctuation in headings
854[MD026]
855punctuation = ".,;:!。,;:!"
856
857# Fenced code blocks only (no indented code blocks)
858[MD046]
859style = "fenced"
860
861# Emphasis with underscores
862[MD049]
863style = "underscore"
864
865# Strong with asterisks
866[MD050]
867style = "asterisk"
868"#
869 .to_string()
870}
871
872fn generate_relaxed_preset() -> String {
875 r#"# rumdl configuration - Relaxed preset
876# Lenient settings for existing projects adopting rumdl incrementally.
877# Minimizes initial warnings while still catching important issues.
878
879[global]
880exclude = [
881 ".git",
882 ".github",
883 "node_modules",
884 "vendor",
885 "dist",
886 "build",
887 "CHANGELOG.md",
888 "LICENSE.md",
889]
890respect-gitignore = true
891
892# Disable rules that produce the most noise on existing projects
893disable = [
894 "MD013", # Line length - most existing files exceed 80 chars
895 "MD033", # Inline HTML - commonly used in real-world markdown
896 "MD041", # First line heading - not all files need it
897]
898
899# Consistent heading style (any style, just be consistent)
900[MD003]
901style = "consistent"
902
903# Consistent list style
904[MD004]
905style = "consistent"
906
907# Consistent emphasis style
908[MD049]
909style = "consistent"
910
911# Consistent strong style
912[MD050]
913style = "consistent"
914"#
915 .to_string()
916}
917
918#[derive(Debug, thiserror::Error)]
920pub enum ConfigError {
921 #[error("Failed to read config file at {path}: {source}")]
923 IoError { source: io::Error, path: String },
924
925 #[error("Failed to parse config: {0}")]
927 ParseError(String),
928
929 #[error("Configuration file already exists at {path}")]
931 FileExists { path: String },
932
933 #[error("Circular extends reference: {path} already in chain {chain:?}")]
935 CircularExtends { path: String, chain: Vec<String> },
936
937 #[error("Extends chain exceeds maximum depth of {max_depth} at {path}")]
939 ExtendsDepthExceeded { path: String, max_depth: usize },
940
941 #[error("extends target not found: {path} (referenced from {from})")]
943 ExtendsNotFound { path: String, from: String },
944
945 #[error("Unknown preset: {name}. Valid presets: default, google, relaxed")]
947 UnknownPreset { name: String },
948}
949
950pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
954 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_config = config.rules.get(&norm_rule_name)?;
957
958 let key_variants = [
960 key.to_string(), normalize_key(key), key.replace('-', "_"), key.replace('_', "-"), ];
965
966 for variant in &key_variants {
968 if let Some(value) = rule_config.values.get(variant)
969 && let Ok(result) = T::deserialize(value.clone())
970 {
971 return Some(result);
972 }
973 }
974
975 None
976}
977
978pub fn generate_pyproject_preset_config(preset: &str) -> Result<String, ConfigError> {
981 match preset {
982 "default" => Ok(generate_pyproject_config()),
983 other => {
984 let rumdl_config = match other {
985 "google" => generate_google_preset(),
986 "relaxed" => generate_relaxed_preset(),
987 _ => {
988 return Err(ConfigError::UnknownPreset {
989 name: other.to_string(),
990 });
991 }
992 };
993 Ok(convert_rumdl_to_pyproject(&rumdl_config))
994 }
995 }
996}
997
998fn convert_rumdl_to_pyproject(rumdl_config: &str) -> String {
1001 let mut output = String::with_capacity(rumdl_config.len() + 128);
1002 for line in rumdl_config.lines() {
1003 let trimmed = line.trim();
1004 if trimmed.starts_with('[') && trimmed.ends_with(']') && !trimmed.starts_with("# [") {
1005 let section = &trimmed[1..trimmed.len() - 1];
1006 if section == "global" {
1007 output.push_str("[tool.rumdl]");
1008 } else {
1009 output.push_str(&format!("[tool.rumdl.{section}]"));
1010 }
1011 } else {
1012 output.push_str(line);
1013 }
1014 output.push('\n');
1015 }
1016 output
1017}
1018
1019pub fn generate_pyproject_config() -> String {
1021 let config_content = r#"
1022[tool.rumdl]
1023# Global configuration options
1024line-length = 100
1025disable = []
1026# extend-enable = ["MD060"] # Add opt-in rules (additive, keeps defaults)
1027# extend-disable = [] # Additional rules to disable (additive)
1028exclude = [
1029 # Common directories to exclude
1030 ".git",
1031 ".github",
1032 "node_modules",
1033 "vendor",
1034 "dist",
1035 "build",
1036]
1037respect-gitignore = true
1038
1039# Rule-specific configurations (uncomment and modify as needed)
1040
1041# [tool.rumdl.MD003]
1042# style = "atx" # Heading style (atx, atx_closed, setext)
1043
1044# [tool.rumdl.MD004]
1045# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
1046
1047# [tool.rumdl.MD007]
1048# indent = 4 # Unordered list indentation
1049
1050# [tool.rumdl.MD013]
1051# line-length = 100 # Line length
1052# code-blocks = false # Exclude code blocks from line length check
1053# tables = false # Exclude tables from line length check
1054# headings = true # Include headings in line length check
1055
1056# [tool.rumdl.MD044]
1057# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
1058# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
1059"#;
1060
1061 config_content.to_string()
1062}