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