1use anyhow::Result;
2use serde::Deserialize;
3use std::collections::{HashMap, HashSet};
4use std::{
5 fs,
6 path::{Path, PathBuf},
7};
8
9use crate::rules::ALL_RULES;
10
11#[derive(Debug, PartialEq, Clone, Deserialize)]
12pub enum RuleSeverity {
13 #[serde(rename = "err")]
14 Error,
15 #[serde(rename = "warn")]
16 Warning,
17 #[serde(rename = "off")]
18 Off,
19}
20
21pub use crate::rules::md003::{HeadingStyle, MD003HeadingStyleTable};
22pub use crate::rules::md004::{MD004UlStyleTable, UlStyle};
23pub use crate::rules::md007::MD007UlIndentTable;
24pub use crate::rules::md009::MD009TrailingSpacesTable;
25pub use crate::rules::md010::MD010HardTabsTable;
26pub use crate::rules::md012::MD012MultipleBlankLinesTable;
27pub use crate::rules::md013::MD013LineLengthTable;
28pub use crate::rules::md022::MD022HeadingsBlanksTable;
29pub use crate::rules::md024::MD024MultipleHeadingsTable;
30pub use crate::rules::md025::MD025SingleH1Table;
31pub use crate::rules::md026::MD026TrailingPunctuationTable;
32pub use crate::rules::md027::MD027BlockquoteSpacesTable;
33pub use crate::rules::md029::{MD029OlPrefixTable, OlPrefixStyle};
34pub use crate::rules::md030::MD030ListMarkerSpaceTable;
35pub use crate::rules::md031::MD031FencedCodeBlanksTable;
36pub use crate::rules::md033::MD033InlineHtmlTable;
37pub use crate::rules::md035::MD035HrStyleTable;
38pub use crate::rules::md036::MD036EmphasisAsHeadingTable;
39pub use crate::rules::md040::MD040FencedCodeLanguageTable;
40pub use crate::rules::md041::MD041FirstLineHeadingTable;
41pub use crate::rules::md043::MD043RequiredHeadingsTable;
42pub use crate::rules::md044::MD044ProperNamesTable;
43pub use crate::rules::md046::{CodeBlockStyle, MD046CodeBlockStyleTable};
44pub use crate::rules::md048::{CodeFenceStyle, MD048CodeFenceStyleTable};
45pub use crate::rules::md049::{EmphasisStyle, MD049EmphasisStyleTable};
46pub use crate::rules::md050::{MD050StrongStyleTable, StrongStyle};
47pub use crate::rules::md051::MD051LinkFragmentsTable;
48pub use crate::rules::md052::MD052ReferenceLinksImagesTable;
49pub use crate::rules::md053::MD053LinkImageReferenceDefinitionsTable;
50pub use crate::rules::md054::MD054LinkImageStyleTable;
51pub use crate::rules::md055::{MD055TablePipeStyleTable, TablePipeStyle};
52pub use crate::rules::md059::MD059DescriptiveLinkTextTable;
53
54#[derive(Debug, Default, PartialEq, Clone, Deserialize)]
55pub struct LintersSettingsTable {
56 #[serde(rename = "heading-style")]
57 #[serde(default)]
58 pub heading_style: MD003HeadingStyleTable,
59 #[serde(rename = "ul-style")]
60 #[serde(default)]
61 pub ul_style: MD004UlStyleTable,
62 #[serde(rename = "ol-prefix")]
63 #[serde(default)]
64 pub ol_prefix: MD029OlPrefixTable,
65 #[serde(rename = "ul-indent")]
66 #[serde(default)]
67 pub ul_indent: MD007UlIndentTable,
68 #[serde(rename = "no-trailing-spaces")]
69 #[serde(default)]
70 pub trailing_spaces: MD009TrailingSpacesTable,
71 #[serde(rename = "no-hard-tabs")]
72 #[serde(default)]
73 pub hard_tabs: MD010HardTabsTable,
74 #[serde(rename = "no-multiple-blanks")]
75 #[serde(default)]
76 pub multiple_blank_lines: MD012MultipleBlankLinesTable,
77 #[serde(rename = "line-length")]
78 #[serde(default)]
79 pub line_length: MD013LineLengthTable,
80 #[serde(rename = "blanks-around-headings")]
81 #[serde(default)]
82 pub headings_blanks: MD022HeadingsBlanksTable,
83 #[serde(rename = "single-h1")]
84 #[serde(default)]
85 pub single_h1: MD025SingleH1Table,
86 #[serde(rename = "first-line-heading")]
87 #[serde(default)]
88 pub first_line_heading: MD041FirstLineHeadingTable,
89 #[serde(rename = "no-trailing-punctuation")]
90 #[serde(default)]
91 pub trailing_punctuation: MD026TrailingPunctuationTable,
92 #[serde(rename = "no-multiple-space-blockquote")]
93 #[serde(default)]
94 pub blockquote_spaces: MD027BlockquoteSpacesTable,
95 #[serde(rename = "list-marker-space")]
96 #[serde(default)]
97 pub list_marker_space: MD030ListMarkerSpaceTable,
98 #[serde(rename = "blanks-around-fences")]
99 #[serde(default)]
100 pub fenced_code_blanks: MD031FencedCodeBlanksTable,
101 #[serde(rename = "no-inline-html")]
102 #[serde(default)]
103 pub inline_html: MD033InlineHtmlTable,
104 #[serde(rename = "hr-style")]
105 #[serde(default)]
106 pub hr_style: MD035HrStyleTable,
107 #[serde(rename = "no-emphasis-as-heading")]
108 #[serde(default)]
109 pub emphasis_as_heading: MD036EmphasisAsHeadingTable,
110 #[serde(rename = "fenced-code-language")]
111 #[serde(default)]
112 pub fenced_code_language: MD040FencedCodeLanguageTable,
113 #[serde(rename = "code-block-style")]
114 #[serde(default)]
115 pub code_block_style: MD046CodeBlockStyleTable,
116 #[serde(rename = "code-fence-style")]
117 #[serde(default)]
118 pub code_fence_style: MD048CodeFenceStyleTable,
119 #[serde(rename = "emphasis-style")]
120 #[serde(default)]
121 pub emphasis_style: MD049EmphasisStyleTable,
122 #[serde(rename = "strong-style")]
123 #[serde(default)]
124 pub strong_style: MD050StrongStyleTable,
125 #[serde(rename = "no-duplicate-heading")]
126 #[serde(default)]
127 pub multiple_headings: MD024MultipleHeadingsTable,
128 #[serde(rename = "required-headings")]
129 #[serde(default)]
130 pub required_headings: MD043RequiredHeadingsTable,
131 #[serde(rename = "proper-names")]
132 #[serde(default)]
133 pub proper_names: MD044ProperNamesTable,
134 #[serde(rename = "link-fragments")]
135 #[serde(default)]
136 pub link_fragments: MD051LinkFragmentsTable,
137 #[serde(rename = "reference-links-images")]
138 #[serde(default)]
139 pub reference_links_images: MD052ReferenceLinksImagesTable,
140 #[serde(rename = "link-image-reference-definitions")]
141 #[serde(default)]
142 pub link_image_reference_definitions: MD053LinkImageReferenceDefinitionsTable,
143 #[serde(rename = "link-image-style")]
144 #[serde(default)]
145 pub link_image_style: MD054LinkImageStyleTable,
146 #[serde(rename = "table-pipe-style")]
147 #[serde(default)]
148 pub table_pipe_style: MD055TablePipeStyleTable,
149 #[serde(rename = "descriptive-link-text")]
150 #[serde(default)]
151 pub descriptive_link_text: MD059DescriptiveLinkTextTable,
152}
153
154#[derive(Debug, Default, PartialEq, Clone, Deserialize)]
155pub struct LintersTable {
156 #[serde(default)]
157 pub severity: HashMap<String, RuleSeverity>,
158 #[serde(default)]
159 pub settings: LintersSettingsTable,
160}
161
162#[derive(Debug, Default, PartialEq, Clone, Deserialize)]
163pub struct QuickmarkConfig {
164 #[serde(default)]
165 pub linters: LintersTable,
166}
167
168pub fn normalize_severities(severities: &mut HashMap<String, RuleSeverity>) {
169 let rule_aliases: HashSet<&str> = ALL_RULES.iter().map(|r| r.alias).collect();
170
171 let default_severity = severities.remove("default").unwrap_or(RuleSeverity::Error);
173
174 severities.retain(|key, _| rule_aliases.contains(key.as_str()));
176
177 for &rule in &rule_aliases {
179 severities
180 .entry(rule.to_string())
181 .or_insert(default_severity.clone());
182 }
183}
184
185impl QuickmarkConfig {
186 pub fn new(linters: LintersTable) -> Self {
187 Self { linters }
188 }
189
190 pub fn default_with_normalized_severities() -> Self {
191 let mut config = Self::default();
192 normalize_severities(&mut config.linters.severity);
193 config
194 }
195}
196
197#[derive(Debug, PartialEq, Clone)]
199pub enum ConfigSearchResult {
200 Found {
202 path: PathBuf,
203 config: Box<QuickmarkConfig>,
204 },
205 NotFound { searched_paths: Vec<PathBuf> },
207 Error { path: PathBuf, error: String },
209}
210
211pub struct ConfigDiscovery {
213 workspace_roots: Vec<PathBuf>,
214}
215
216impl Default for ConfigDiscovery {
217 fn default() -> Self {
218 Self::new()
219 }
220}
221
222impl ConfigDiscovery {
223 pub fn new() -> Self {
225 Self {
226 workspace_roots: Vec::new(),
227 }
228 }
229
230 pub fn with_workspace_roots(roots: Vec<PathBuf>) -> Self {
232 Self {
233 workspace_roots: roots,
234 }
235 }
236
237 pub fn find_config(&self, file_path: &Path) -> ConfigSearchResult {
239 let start_dir = if file_path.is_file() {
240 file_path.parent().unwrap_or(file_path)
241 } else {
242 file_path
243 };
244
245 let mut searched_paths = Vec::new();
246 let mut current_dir = start_dir;
247
248 loop {
249 let config_path = current_dir.join("quickmark.toml");
250 searched_paths.push(config_path.clone());
251
252 if config_path.is_file() {
253 match fs::read_to_string(&config_path) {
254 Ok(config_str) => match parse_toml_config(&config_str) {
255 Ok(config) => {
256 return ConfigSearchResult::Found {
257 path: config_path,
258 config: Box::new(config),
259 }
260 }
261 Err(e) => {
262 return ConfigSearchResult::Error {
263 path: config_path,
264 error: e.to_string(),
265 }
266 }
267 },
268 Err(e) => {
269 return ConfigSearchResult::Error {
270 path: config_path,
271 error: e.to_string(),
272 }
273 }
274 }
275 }
276
277 if self.should_stop_search(current_dir) {
279 break;
280 }
281
282 match current_dir.parent() {
284 Some(parent) => current_dir = parent,
285 None => break, }
287 }
288
289 ConfigSearchResult::NotFound { searched_paths }
290 }
291
292 fn should_stop_search(&self, dir: &Path) -> bool {
294 for workspace_root in &self.workspace_roots {
296 if dir == workspace_root.as_path() {
297 return true;
298 }
299 }
300
301 if dir.join(".git").exists() {
303 return true;
304 }
305
306 let project_markers = [
308 "package.json",
309 "Cargo.toml",
310 "pyproject.toml",
311 "go.mod",
312 ".vscode",
313 ".idea",
314 ".sublime-project",
315 ];
316
317 for marker in &project_markers {
318 if dir.join(marker).exists() {
319 return true;
320 }
321 }
322
323 false
324 }
325}
326
327pub fn parse_toml_config(config_str: &str) -> Result<QuickmarkConfig> {
329 let mut config: QuickmarkConfig = toml::from_str(config_str)?;
330 normalize_severities(&mut config.linters.severity);
331 Ok(config)
332}
333
334pub fn config_from_env_path_or_default(path: &Path) -> Result<QuickmarkConfig> {
336 if let Ok(env_config_path) = std::env::var("QUICKMARK_CONFIG") {
338 let env_config_file = Path::new(&env_config_path);
339 if env_config_file.is_file() {
340 match fs::read_to_string(env_config_file) {
341 Ok(config) => return parse_toml_config(&config),
342 Err(e) => {
343 eprintln!(
344 "Error loading config from QUICKMARK_CONFIG path {env_config_path}: {e}. Default config will be used."
345 );
346 return Ok(QuickmarkConfig::default_with_normalized_severities());
347 }
348 }
349 } else {
350 eprintln!(
351 "Config file was not found at QUICKMARK_CONFIG path {env_config_path}. Default config will be used."
352 );
353 return Ok(QuickmarkConfig::default_with_normalized_severities());
354 }
355 }
356
357 config_in_path_or_default(path)
359}
360
361pub fn config_in_path_or_default(path: &Path) -> Result<QuickmarkConfig> {
363 let config_file = path.join("quickmark.toml");
364 if config_file.is_file() {
365 let config = fs::read_to_string(config_file)?;
366 return parse_toml_config(&config);
367 }
368 println!(
369 "Config file was not found at {}. Default config will be used.",
370 config_file.to_string_lossy()
371 );
372 Ok(QuickmarkConfig::default_with_normalized_severities())
373}
374
375pub fn discover_config_or_default(file_path: &Path) -> Result<QuickmarkConfig> {
377 let discovery = ConfigDiscovery::new();
378 match discovery.find_config(file_path) {
379 ConfigSearchResult::Found { config, .. } => Ok(*config),
380 ConfigSearchResult::NotFound { .. } => {
381 Ok(QuickmarkConfig::default_with_normalized_severities())
382 }
383 ConfigSearchResult::Error { path, error } => {
384 eprintln!(
385 "Error loading config from {}: {}. Default config will be used.",
386 path.to_string_lossy(),
387 error
388 );
389 Ok(QuickmarkConfig::default_with_normalized_severities())
390 }
391 }
392}
393
394pub fn discover_config_with_workspace_or_default(
396 file_path: &Path,
397 workspace_roots: Vec<PathBuf>,
398) -> Result<QuickmarkConfig> {
399 let discovery = ConfigDiscovery::with_workspace_roots(workspace_roots);
400 match discovery.find_config(file_path) {
401 ConfigSearchResult::Found { config, .. } => Ok(*config),
402 ConfigSearchResult::NotFound { .. } => {
403 Ok(QuickmarkConfig::default_with_normalized_severities())
404 }
405 ConfigSearchResult::Error { path, error } => {
406 eprintln!(
407 "Error loading config from {}: {}. Default config will be used.",
408 path.to_string_lossy(),
409 error
410 );
411 Ok(QuickmarkConfig::default_with_normalized_severities())
412 }
413 }
414}
415
416#[cfg(test)]
417mod test {
418 use std::collections::HashMap;
419 use std::path::Path;
420 use tempfile::TempDir;
421
422 use crate::config::{
423 config_from_env_path_or_default, discover_config_or_default,
424 discover_config_with_workspace_or_default, parse_toml_config, ConfigDiscovery,
425 ConfigSearchResult, HeadingStyle, LintersSettingsTable, LintersTable,
426 MD003HeadingStyleTable, MD004UlStyleTable, MD007UlIndentTable, MD009TrailingSpacesTable,
427 MD010HardTabsTable, MD012MultipleBlankLinesTable, MD013LineLengthTable,
428 MD022HeadingsBlanksTable, MD024MultipleHeadingsTable, MD025SingleH1Table,
429 MD026TrailingPunctuationTable, MD027BlockquoteSpacesTable, MD029OlPrefixTable,
430 MD030ListMarkerSpaceTable, MD031FencedCodeBlanksTable, MD033InlineHtmlTable,
431 MD035HrStyleTable, MD036EmphasisAsHeadingTable, MD040FencedCodeLanguageTable,
432 MD041FirstLineHeadingTable, MD043RequiredHeadingsTable, MD044ProperNamesTable,
433 MD046CodeBlockStyleTable, MD048CodeFenceStyleTable, MD049EmphasisStyleTable,
434 MD050StrongStyleTable, MD051LinkFragmentsTable, MD052ReferenceLinksImagesTable,
435 MD053LinkImageReferenceDefinitionsTable, MD054LinkImageStyleTable,
436 MD055TablePipeStyleTable, MD059DescriptiveLinkTextTable, RuleSeverity,
437 };
438
439 use super::{normalize_severities, QuickmarkConfig};
440
441 #[test]
442 pub fn test_normalize_severities() {
443 let mut severity: HashMap<String, RuleSeverity> = vec![
444 ("heading-style".to_string(), RuleSeverity::Error),
445 ("some-bullshit".to_string(), RuleSeverity::Warning),
446 ]
447 .into_iter()
448 .collect();
449
450 normalize_severities(&mut severity);
451
452 assert_eq!(
453 RuleSeverity::Error,
454 *severity.get("heading-increment").unwrap()
455 );
456 assert_eq!(RuleSeverity::Error, *severity.get("heading-style").unwrap());
457 assert_eq!(RuleSeverity::Error, *severity.get("list-indent").unwrap());
458 assert_eq!(
459 RuleSeverity::Error,
460 *severity.get("no-reversed-links").unwrap()
461 );
462 assert_eq!(None, severity.get("some-bullshit"));
463 }
464
465 #[test]
466 pub fn test_default_with_normalized_severities() {
467 let config = QuickmarkConfig::default_with_normalized_severities();
468 assert_eq!(
469 RuleSeverity::Error,
470 *config.linters.severity.get("heading-increment").unwrap()
471 );
472 assert_eq!(
473 RuleSeverity::Error,
474 *config.linters.severity.get("heading-style").unwrap()
475 );
476 assert_eq!(
477 RuleSeverity::Error,
478 *config.linters.severity.get("list-indent").unwrap()
479 );
480 assert_eq!(
481 RuleSeverity::Error,
482 *config.linters.severity.get("no-reversed-links").unwrap()
483 );
484 assert_eq!(
485 HeadingStyle::Consistent,
486 config.linters.settings.heading_style.style
487 );
488 }
489
490 #[test]
491 pub fn test_new_config() {
492 let severity: HashMap<String, RuleSeverity> = vec![
493 ("heading-increment".to_string(), RuleSeverity::Warning),
494 ("heading-style".to_string(), RuleSeverity::Off),
495 ]
496 .into_iter()
497 .collect();
498
499 let config = QuickmarkConfig::new(LintersTable {
500 severity,
501 settings: LintersSettingsTable {
502 heading_style: MD003HeadingStyleTable {
503 style: HeadingStyle::ATX,
504 },
505 ul_style: MD004UlStyleTable::default(),
506 ol_prefix: MD029OlPrefixTable::default(),
507 list_marker_space: MD030ListMarkerSpaceTable::default(),
508 ul_indent: MD007UlIndentTable::default(),
509 trailing_spaces: MD009TrailingSpacesTable::default(),
510 hard_tabs: MD010HardTabsTable::default(),
511 multiple_blank_lines: MD012MultipleBlankLinesTable::default(),
512 line_length: MD013LineLengthTable::default(),
513 headings_blanks: MD022HeadingsBlanksTable::default(),
514 single_h1: MD025SingleH1Table::default(),
515 first_line_heading: MD041FirstLineHeadingTable::default(),
516 trailing_punctuation: MD026TrailingPunctuationTable::default(),
517 blockquote_spaces: MD027BlockquoteSpacesTable::default(),
518 fenced_code_blanks: MD031FencedCodeBlanksTable::default(),
519 inline_html: MD033InlineHtmlTable::default(),
520 hr_style: MD035HrStyleTable::default(),
521 emphasis_as_heading: MD036EmphasisAsHeadingTable::default(),
522 fenced_code_language: MD040FencedCodeLanguageTable::default(),
523 code_block_style: MD046CodeBlockStyleTable::default(),
524 code_fence_style: MD048CodeFenceStyleTable::default(),
525 emphasis_style: MD049EmphasisStyleTable::default(),
526 strong_style: MD050StrongStyleTable::default(),
527 multiple_headings: MD024MultipleHeadingsTable::default(),
528 required_headings: MD043RequiredHeadingsTable::default(),
529 proper_names: MD044ProperNamesTable::default(),
530 link_fragments: MD051LinkFragmentsTable::default(),
531 reference_links_images: MD052ReferenceLinksImagesTable::default(),
532 link_image_reference_definitions: MD053LinkImageReferenceDefinitionsTable::default(
533 ),
534 link_image_style: MD054LinkImageStyleTable::default(),
535 table_pipe_style: MD055TablePipeStyleTable::default(),
536 descriptive_link_text: MD059DescriptiveLinkTextTable::default(),
537 },
538 });
539
540 assert_eq!(
541 RuleSeverity::Warning,
542 *config.linters.severity.get("heading-increment").unwrap()
543 );
544 assert_eq!(
545 RuleSeverity::Off,
546 *config.linters.severity.get("heading-style").unwrap()
547 );
548 assert_eq!(
549 HeadingStyle::ATX,
550 config.linters.settings.heading_style.style
551 );
552 }
553
554 #[test]
555 fn test_parse_toml_config_with_invalid_rules() {
556 let config_str = r#"
557 [linters.severity]
558 heading-style = 'err'
559 some-invalid-rule = 'warn'
560
561 [linters.settings.heading-style]
562 style = 'atx'
563 "#;
564
565 let parsed = parse_toml_config(config_str).unwrap();
566 assert_eq!(
567 RuleSeverity::Error,
568 *parsed.linters.severity.get("heading-increment").unwrap()
569 );
570 assert_eq!(
571 RuleSeverity::Error,
572 *parsed.linters.severity.get("heading-style").unwrap()
573 );
574 assert_eq!(None, parsed.linters.severity.get("some-invalid-rule"));
575 }
576
577 #[test]
578 fn test_config_from_env_fallback_to_local() {
579 let temp_dir = tempfile::tempdir().unwrap();
581 let config_path = temp_dir.path().join("quickmark.toml");
582 let config_content = r#"
583 [linters.severity]
584 heading-increment = 'err'
585 heading-style = 'off'
586 "#;
587
588 std::fs::write(&config_path, config_content).unwrap();
589
590 let config = config_from_env_path_or_default(temp_dir.path()).unwrap();
592
593 assert_eq!(
594 RuleSeverity::Error,
595 *config.linters.severity.get("heading-increment").unwrap()
596 );
597 assert_eq!(
598 RuleSeverity::Off,
599 *config.linters.severity.get("heading-style").unwrap()
600 );
601 }
602
603 #[test]
604 fn test_config_from_env_default_when_no_config() {
605 let dummy_path = Path::new("/tmp");
606 let config = config_from_env_path_or_default(dummy_path).unwrap();
607
608 assert_eq!(
610 RuleSeverity::Error,
611 *config.linters.severity.get("heading-increment").unwrap()
612 );
613 assert_eq!(
614 RuleSeverity::Error,
615 *config.linters.severity.get("heading-style").unwrap()
616 );
617 }
618
619 #[test]
620 fn test_parse_full_config_with_custom_parameters() {
621 let config_str = r#"
622 [linters.severity]
623 heading-style = 'warn'
624 ul-style = 'off'
625 line-length = 'err'
626
627 [linters.settings.heading-style]
628 style = 'atx'
629
630 [linters.settings.ul-style]
631 style = 'asterisk'
632
633 [linters.settings.ol-prefix]
634 style = 'one'
635
636 [linters.settings.ul-indent]
637 indent = 4
638 start_indent = 3
639 start_indented = true
640
641 [linters.settings.no-trailing-spaces]
642 br_spaces = 3
643 list_item_empty_lines = true
644 strict = true
645
646 [linters.settings.no-hard-tabs]
647 code_blocks = false
648 ignore_code_languages = ["python", "go"]
649 spaces_per_tab = 8
650
651 [linters.settings.no-multiple-blanks]
652 maximum = 3
653
654 [linters.settings.line-length]
655 line_length = 120
656 code_block_line_length = 100
657 heading_line_length = 90
658 code_blocks = false
659 headings = false
660 tables = false
661 strict = true
662 stern = true
663
664 [linters.settings.blanks-around-headings]
665 lines_above = [2, 1, 1, 1, 1, 1]
666 lines_below = [2, 1, 1, 1, 1, 1]
667
668 [linters.settings.single-h1]
669 level = 2
670 front_matter_title = "^title:"
671
672 [linters.settings.first-line-heading]
673 allow_preamble = true
674
675 [linters.settings.no-trailing-punctuation]
676 punctuation = ".,;:!?"
677
678 [linters.settings.no-multiple-space-blockquote]
679 list_items = false
680
681 [linters.settings.list-marker-space]
682 ul_single = 2
683 ol_single = 3
684 ul_multi = 3
685 ol_multi = 4
686
687 [linters.settings.blanks-around-fences]
688 list_items = false
689
690 [linters.settings.no-inline-html]
691 allowed_elements = ["br", "img"]
692
693 [linters.settings.hr-style]
694 style = "asterisk"
695
696 [linters.settings.no-emphasis-as-heading]
697 punctuation = ".,;:!?"
698
699 [linters.settings.fenced-code-language]
700 allowed_languages = ["rust", "python"]
701 language_only = true
702
703 [linters.settings.code-block-style]
704 style = 'fenced'
705
706 [linters.settings.code-fence-style]
707 style = 'backtick'
708
709 [linters.settings.emphasis-style]
710 style = 'asterisk'
711
712 [linters.settings.strong-style]
713 style = 'underscore'
714
715 [linters.settings.no-duplicate-heading]
716 siblings_only = false
717 allow_different_nesting = false
718
719 [linters.settings.required-headings]
720 headings = ["Introduction", "Usage", "Examples"]
721 match_case = true
722
723 [linters.settings.proper-names]
724 names = ["JavaScript", "GitHub", "API"]
725 code_blocks = false
726 html_elements = false
727
728 [linters.settings.link-fragments]
729
730 [linters.settings.reference-links-images]
731 ignored_labels = ["x", "skip"]
732
733 [linters.settings.link-image-reference-definitions]
734 ignored_definitions = ["//", "skip"]
735
736 [linters.settings.link-image-style]
737 autolink = false
738 inline = true
739 full = true
740 collapsed = false
741 shortcut = false
742 url_inline = false
743
744 [linters.settings.table-pipe-style]
745 style = 'leading_and_trailing'
746
747 [linters.settings.descriptive-link-text]
748 prohibited_texts = ["click here", "read more", "see here"]
749 "#;
750
751 let parsed = parse_toml_config(config_str).unwrap();
752
753 assert_eq!(
755 RuleSeverity::Warning,
756 *parsed.linters.severity.get("heading-style").unwrap()
757 );
758 assert_eq!(
759 RuleSeverity::Off,
760 *parsed.linters.severity.get("ul-style").unwrap()
761 );
762 assert_eq!(
763 RuleSeverity::Error,
764 *parsed.linters.severity.get("line-length").unwrap()
765 );
766
767 assert_eq!(
769 HeadingStyle::ATX,
770 parsed.linters.settings.heading_style.style
771 );
772
773 use crate::rules::md004::UlStyle;
775 assert_eq!(UlStyle::Asterisk, parsed.linters.settings.ul_style.style);
776
777 assert_eq!(4, parsed.linters.settings.ul_indent.indent);
779 assert_eq!(3, parsed.linters.settings.ul_indent.start_indent);
780 assert!(parsed.linters.settings.ul_indent.start_indented);
781
782 assert_eq!(3, parsed.linters.settings.trailing_spaces.br_spaces);
784 assert!(
785 parsed
786 .linters
787 .settings
788 .trailing_spaces
789 .list_item_empty_lines
790 );
791 assert!(parsed.linters.settings.trailing_spaces.strict);
792
793 assert_eq!(120, parsed.linters.settings.line_length.line_length);
795 assert_eq!(
796 100,
797 parsed.linters.settings.line_length.code_block_line_length
798 );
799 assert_eq!(90, parsed.linters.settings.line_length.heading_line_length);
800 assert!(!parsed.linters.settings.line_length.code_blocks);
801 assert!(!parsed.linters.settings.line_length.headings);
802 assert!(!parsed.linters.settings.line_length.tables);
803 assert!(parsed.linters.settings.line_length.strict);
804 assert!(parsed.linters.settings.line_length.stern);
805
806 assert_eq!(2, parsed.linters.settings.single_h1.level);
808 assert_eq!(
809 "^title:",
810 parsed.linters.settings.single_h1.front_matter_title
811 );
812
813 use crate::rules::md029::OlPrefixStyle;
815 assert_eq!(OlPrefixStyle::One, parsed.linters.settings.ol_prefix.style);
816
817 assert!(!parsed.linters.settings.hard_tabs.code_blocks);
819 assert_eq!(
820 vec!["python", "go"],
821 parsed.linters.settings.hard_tabs.ignore_code_languages
822 );
823 assert_eq!(8, parsed.linters.settings.hard_tabs.spaces_per_tab);
824
825 assert_eq!(3, parsed.linters.settings.multiple_blank_lines.maximum);
827
828 assert_eq!(
830 vec![2, 1, 1, 1, 1, 1],
831 parsed.linters.settings.headings_blanks.lines_above
832 );
833 assert_eq!(
834 vec![2, 1, 1, 1, 1, 1],
835 parsed.linters.settings.headings_blanks.lines_below
836 );
837
838 assert!(parsed.linters.settings.first_line_heading.allow_preamble);
840
841 assert_eq!(
843 ".,;:!?",
844 parsed.linters.settings.trailing_punctuation.punctuation
845 );
846
847 assert!(!parsed.linters.settings.blockquote_spaces.list_items);
849
850 assert_eq!(2, parsed.linters.settings.list_marker_space.ul_single);
852 assert_eq!(3, parsed.linters.settings.list_marker_space.ol_single);
853 assert_eq!(3, parsed.linters.settings.list_marker_space.ul_multi);
854 assert_eq!(4, parsed.linters.settings.list_marker_space.ol_multi);
855
856 assert!(!parsed.linters.settings.fenced_code_blanks.list_items);
858
859 assert_eq!(
861 vec!["br", "img"],
862 parsed.linters.settings.inline_html.allowed_elements
863 );
864
865 assert_eq!("asterisk", parsed.linters.settings.hr_style.style);
867
868 assert_eq!(
870 ".,;:!?",
871 parsed.linters.settings.emphasis_as_heading.punctuation
872 );
873
874 assert_eq!(
876 vec!["rust", "python"],
877 parsed
878 .linters
879 .settings
880 .fenced_code_language
881 .allowed_languages
882 );
883 assert!(parsed.linters.settings.fenced_code_language.language_only);
884
885 use crate::rules::md046::CodeBlockStyle;
887 assert_eq!(
888 CodeBlockStyle::Fenced,
889 parsed.linters.settings.code_block_style.style
890 );
891
892 use crate::rules::md048::CodeFenceStyle;
894 assert_eq!(
895 CodeFenceStyle::Backtick,
896 parsed.linters.settings.code_fence_style.style
897 );
898
899 use crate::rules::md049::EmphasisStyle;
901 assert_eq!(
902 EmphasisStyle::Asterisk,
903 parsed.linters.settings.emphasis_style.style
904 );
905
906 use crate::rules::md050::StrongStyle;
908 assert_eq!(
909 StrongStyle::Underscore,
910 parsed.linters.settings.strong_style.style
911 );
912
913 assert!(!parsed.linters.settings.multiple_headings.siblings_only);
915 assert!(
916 !parsed
917 .linters
918 .settings
919 .multiple_headings
920 .allow_different_nesting
921 );
922
923 assert_eq!(
925 vec!["Introduction", "Usage", "Examples"],
926 parsed.linters.settings.required_headings.headings
927 );
928 assert!(parsed.linters.settings.required_headings.match_case);
929
930 assert_eq!(
932 vec!["JavaScript", "GitHub", "API"],
933 parsed.linters.settings.proper_names.names
934 );
935 assert!(!parsed.linters.settings.proper_names.code_blocks);
936 assert!(!parsed.linters.settings.proper_names.html_elements);
937
938 assert_eq!(
940 vec!["x", "skip"],
941 parsed
942 .linters
943 .settings
944 .reference_links_images
945 .ignored_labels
946 );
947
948 assert_eq!(
950 vec!["//", "skip"],
951 parsed
952 .linters
953 .settings
954 .link_image_reference_definitions
955 .ignored_definitions
956 );
957
958 assert!(!parsed.linters.settings.link_image_style.autolink);
960 assert!(parsed.linters.settings.link_image_style.inline);
961 assert!(parsed.linters.settings.link_image_style.full);
962 assert!(!parsed.linters.settings.link_image_style.collapsed);
963 assert!(!parsed.linters.settings.link_image_style.shortcut);
964 assert!(!parsed.linters.settings.link_image_style.url_inline);
965
966 use crate::rules::md055::TablePipeStyle;
968 assert_eq!(
969 TablePipeStyle::LeadingAndTrailing,
970 parsed.linters.settings.table_pipe_style.style
971 );
972
973 assert_eq!(
975 vec!["click here", "read more", "see here"],
976 parsed
977 .linters
978 .settings
979 .descriptive_link_text
980 .prohibited_texts
981 );
982 }
983
984 #[test]
985 fn test_parse_empty_config_uses_defaults() {
986 let config_str = r#"
987 # Empty config - should use all defaults
988 "#;
989
990 let parsed = parse_toml_config(config_str).unwrap();
991
992 assert_eq!(
994 RuleSeverity::Error,
995 *parsed.linters.severity.get("heading-style").unwrap()
996 );
997 assert_eq!(
998 RuleSeverity::Error,
999 *parsed.linters.severity.get("ul-style").unwrap()
1000 );
1001 assert_eq!(
1002 RuleSeverity::Error,
1003 *parsed.linters.severity.get("line-length").unwrap()
1004 );
1005 assert_eq!(
1006 RuleSeverity::Error,
1007 *parsed.linters.severity.get("ul-indent").unwrap()
1008 );
1009
1010 assert_eq!(
1012 HeadingStyle::Consistent,
1013 parsed.linters.settings.heading_style.style
1014 );
1015
1016 use crate::rules::md004::UlStyle;
1018 assert_eq!(UlStyle::Consistent, parsed.linters.settings.ul_style.style);
1019
1020 assert_eq!(2, parsed.linters.settings.ul_indent.indent);
1022 assert_eq!(2, parsed.linters.settings.ul_indent.start_indent);
1023 assert!(!parsed.linters.settings.ul_indent.start_indented);
1024
1025 assert_eq!(2, parsed.linters.settings.trailing_spaces.br_spaces);
1027 assert!(
1028 !parsed
1029 .linters
1030 .settings
1031 .trailing_spaces
1032 .list_item_empty_lines
1033 );
1034 assert!(!parsed.linters.settings.trailing_spaces.strict);
1035
1036 assert_eq!(80, parsed.linters.settings.line_length.line_length);
1038 assert_eq!(
1039 80,
1040 parsed.linters.settings.line_length.code_block_line_length
1041 );
1042 assert_eq!(80, parsed.linters.settings.line_length.heading_line_length);
1043 assert!(parsed.linters.settings.line_length.code_blocks);
1044 assert!(parsed.linters.settings.line_length.headings);
1045 assert!(parsed.linters.settings.line_length.tables);
1046 assert!(!parsed.linters.settings.line_length.strict);
1047 assert!(!parsed.linters.settings.line_length.stern);
1048
1049 assert_eq!(1, parsed.linters.settings.single_h1.level);
1051 assert_eq!(
1052 r"^\s*title\s*[:=]",
1053 parsed.linters.settings.single_h1.front_matter_title
1054 );
1055
1056 use crate::rules::md029::OlPrefixStyle;
1058 assert_eq!(
1059 OlPrefixStyle::OneOrOrdered,
1060 parsed.linters.settings.ol_prefix.style
1061 );
1062
1063 assert_eq!(1, parsed.linters.settings.multiple_blank_lines.maximum);
1065
1066 assert_eq!(1, parsed.linters.settings.hard_tabs.spaces_per_tab);
1068 assert!(parsed.linters.settings.hard_tabs.code_blocks);
1069
1070 assert!(!parsed.linters.settings.first_line_heading.allow_preamble);
1072 }
1073
1074 #[test]
1075 fn test_default_severity_error() {
1076 let config_str = r#"
1077 [linters.severity]
1078 default = "err"
1079 heading-style = "warn"
1080 ul-style = "off"
1081 "#;
1082
1083 let parsed = parse_toml_config(config_str).unwrap();
1084
1085 assert_eq!(
1087 RuleSeverity::Warning,
1088 *parsed.linters.severity.get("heading-style").unwrap()
1089 );
1090 assert_eq!(
1091 RuleSeverity::Off,
1092 *parsed.linters.severity.get("ul-style").unwrap()
1093 );
1094
1095 assert_eq!(
1097 RuleSeverity::Error,
1098 *parsed.linters.severity.get("line-length").unwrap()
1099 );
1100 assert_eq!(
1101 RuleSeverity::Error,
1102 *parsed.linters.severity.get("ul-indent").unwrap()
1103 );
1104 assert_eq!(
1105 RuleSeverity::Error,
1106 *parsed.linters.severity.get("no-hard-tabs").unwrap()
1107 );
1108
1109 assert_eq!(None, parsed.linters.severity.get("default"));
1111 }
1112
1113 #[test]
1114 fn test_default_severity_warning() {
1115 let config_str = r#"
1116 [linters.severity]
1117 default = "warn"
1118 heading-style = "err"
1119 "#;
1120
1121 let parsed = parse_toml_config(config_str).unwrap();
1122
1123 assert_eq!(
1125 RuleSeverity::Error,
1126 *parsed.linters.severity.get("heading-style").unwrap()
1127 );
1128
1129 assert_eq!(
1131 RuleSeverity::Warning,
1132 *parsed.linters.severity.get("ul-style").unwrap()
1133 );
1134 assert_eq!(
1135 RuleSeverity::Warning,
1136 *parsed.linters.severity.get("line-length").unwrap()
1137 );
1138 assert_eq!(
1139 RuleSeverity::Warning,
1140 *parsed.linters.severity.get("ul-indent").unwrap()
1141 );
1142
1143 assert_eq!(None, parsed.linters.severity.get("default"));
1145 }
1146
1147 #[test]
1148 fn test_default_severity_off() {
1149 let config_str = r#"
1150 [linters.severity]
1151 default = "off"
1152 heading-style = "err"
1153 line-length = "warn"
1154 "#;
1155
1156 let parsed = parse_toml_config(config_str).unwrap();
1157
1158 assert_eq!(
1160 RuleSeverity::Error,
1161 *parsed.linters.severity.get("heading-style").unwrap()
1162 );
1163 assert_eq!(
1164 RuleSeverity::Warning,
1165 *parsed.linters.severity.get("line-length").unwrap()
1166 );
1167
1168 assert_eq!(
1170 RuleSeverity::Off,
1171 *parsed.linters.severity.get("ul-style").unwrap()
1172 );
1173 assert_eq!(
1174 RuleSeverity::Off,
1175 *parsed.linters.severity.get("ul-indent").unwrap()
1176 );
1177 assert_eq!(
1178 RuleSeverity::Off,
1179 *parsed.linters.severity.get("no-hard-tabs").unwrap()
1180 );
1181
1182 assert_eq!(None, parsed.linters.severity.get("default"));
1184 }
1185
1186 #[test]
1187 fn test_default_severity_with_invalid_rules() {
1188 let config_str = r#"
1189 [linters.severity]
1190 default = "warn"
1191 heading-style = "err"
1192 invalid-rule = "off"
1193 another-invalid = "warn"
1194 ul-style = "off"
1195 "#;
1196
1197 let parsed = parse_toml_config(config_str).unwrap();
1198
1199 assert_eq!(
1201 RuleSeverity::Error,
1202 *parsed.linters.severity.get("heading-style").unwrap()
1203 );
1204 assert_eq!(
1205 RuleSeverity::Off,
1206 *parsed.linters.severity.get("ul-style").unwrap()
1207 );
1208
1209 assert_eq!(None, parsed.linters.severity.get("invalid-rule"));
1211 assert_eq!(None, parsed.linters.severity.get("another-invalid"));
1212
1213 assert_eq!(
1215 RuleSeverity::Warning,
1216 *parsed.linters.severity.get("line-length").unwrap()
1217 );
1218 assert_eq!(
1219 RuleSeverity::Warning,
1220 *parsed.linters.severity.get("ul-indent").unwrap()
1221 );
1222
1223 assert_eq!(None, parsed.linters.severity.get("default"));
1225 }
1226
1227 #[test]
1228 fn test_no_default_uses_error() {
1229 let config_str = r#"
1230 [linters.severity]
1231 heading-style = "warn"
1232 ul-style = "off"
1233 "#;
1234
1235 let parsed = parse_toml_config(config_str).unwrap();
1236
1237 assert_eq!(
1239 RuleSeverity::Warning,
1240 *parsed.linters.severity.get("heading-style").unwrap()
1241 );
1242 assert_eq!(
1243 RuleSeverity::Off,
1244 *parsed.linters.severity.get("ul-style").unwrap()
1245 );
1246
1247 assert_eq!(
1249 RuleSeverity::Error,
1250 *parsed.linters.severity.get("line-length").unwrap()
1251 );
1252 assert_eq!(
1253 RuleSeverity::Error,
1254 *parsed.linters.severity.get("ul-indent").unwrap()
1255 );
1256 }
1257
1258 #[test]
1259 fn test_config_discovery_not_found() {
1260 let temp_dir = TempDir::new().unwrap();
1261 let file_path = temp_dir.path().join("test.md");
1262
1263 let discovery = ConfigDiscovery::new();
1264 let result = discovery.find_config(&file_path);
1265
1266 match result {
1267 ConfigSearchResult::NotFound { searched_paths } => {
1268 assert!(!searched_paths.is_empty());
1269 assert!(searched_paths
1271 .iter()
1272 .any(|p| p.parent() == Some(temp_dir.path())));
1273 }
1274 _ => panic!("Expected NotFound result"),
1275 }
1276 }
1277
1278 #[test]
1279 fn test_config_discovery_found() {
1280 let temp_dir = TempDir::new().unwrap();
1281
1282 let config_path = temp_dir.path().join("quickmark.toml");
1284 let config_content = r#"
1285 [linters.severity]
1286 heading-style = 'warn'
1287 "#;
1288 std::fs::write(&config_path, config_content).unwrap();
1289
1290 let file_path = temp_dir.path().join("test.md");
1292 std::fs::write(&file_path, "# Test").unwrap();
1293
1294 let discovery = ConfigDiscovery::new();
1295 let result = discovery.find_config(&file_path);
1296
1297 match result {
1298 ConfigSearchResult::Found { path, config } => {
1299 assert_eq!(path, config_path);
1300 assert_eq!(
1301 *config.linters.severity.get("heading-style").unwrap(),
1302 RuleSeverity::Warning
1303 );
1304 }
1305 _ => panic!("Expected Found result, got: {:?}", result),
1306 }
1307 }
1308
1309 #[test]
1310 fn test_config_discovery_hierarchical_search() {
1311 let temp_dir = TempDir::new().unwrap();
1312
1313 let project_dir = temp_dir.path().join("project");
1315 let src_dir = project_dir.join("src");
1316 std::fs::create_dir_all(&src_dir).unwrap();
1317
1318 let config_path = project_dir.join("quickmark.toml");
1320 let config_content = r#"
1321 [linters.severity]
1322 heading-style = 'off'
1323 "#;
1324 std::fs::write(&config_path, config_content).unwrap();
1325
1326 let file_path = src_dir.join("test.md");
1328 std::fs::write(&file_path, "# Test").unwrap();
1329
1330 let discovery = ConfigDiscovery::new();
1331 let result = discovery.find_config(&file_path);
1332
1333 match result {
1334 ConfigSearchResult::Found { path, config } => {
1335 assert_eq!(path, config_path);
1336 assert_eq!(
1337 *config.linters.severity.get("heading-style").unwrap(),
1338 RuleSeverity::Off
1339 );
1340 }
1341 _ => panic!("Expected Found result, got: {:?}", result),
1342 }
1343 }
1344
1345 #[test]
1346 fn test_config_discovery_stops_at_git_root() {
1347 let temp_dir = TempDir::new().unwrap();
1348
1349 let repo_dir = temp_dir.path().join("repo");
1351 let src_dir = repo_dir.join("src");
1352 std::fs::create_dir_all(&src_dir).unwrap();
1353
1354 std::fs::create_dir(repo_dir.join(".git")).unwrap();
1356
1357 let outer_config = temp_dir.path().join("quickmark.toml");
1359 std::fs::write(&outer_config, "[linters.severity]\nheading-style = 'warn'").unwrap();
1360
1361 let file_path = src_dir.join("test.md");
1363 std::fs::write(&file_path, "# Test").unwrap();
1364
1365 let discovery = ConfigDiscovery::new();
1366 let result = discovery.find_config(&file_path);
1367
1368 match result {
1369 ConfigSearchResult::NotFound { searched_paths } => {
1370 let searched_dirs: Vec<_> =
1372 searched_paths.iter().filter_map(|p| p.parent()).collect();
1373 assert!(searched_dirs.contains(&src_dir.as_path()));
1374 assert!(searched_dirs.contains(&repo_dir.as_path()));
1375 assert!(!searched_dirs.contains(&temp_dir.path()));
1376 }
1377 _ => panic!("Expected NotFound result, got: {:?}", result),
1378 }
1379 }
1380
1381 #[test]
1382 fn test_config_discovery_stops_at_workspace_root() {
1383 let temp_dir = TempDir::new().unwrap();
1384
1385 let workspace_dir = temp_dir.path().join("workspace");
1387 let project_dir = workspace_dir.join("project");
1388 let src_dir = project_dir.join("src");
1389 std::fs::create_dir_all(&src_dir).unwrap();
1390
1391 let outer_config = temp_dir.path().join("quickmark.toml");
1393 std::fs::write(&outer_config, "[linters.severity]\nheading-style = 'warn'").unwrap();
1394
1395 let file_path = src_dir.join("test.md");
1397 std::fs::write(&file_path, "# Test").unwrap();
1398
1399 let discovery = ConfigDiscovery::with_workspace_roots(vec![workspace_dir.clone()]);
1400 let result = discovery.find_config(&file_path);
1401
1402 match result {
1403 ConfigSearchResult::NotFound { searched_paths } => {
1404 let searched_dirs: Vec<_> =
1406 searched_paths.iter().filter_map(|p| p.parent()).collect();
1407 assert!(searched_dirs.contains(&src_dir.as_path()));
1408 assert!(searched_dirs.contains(&project_dir.as_path()));
1409 assert!(searched_dirs.contains(&workspace_dir.as_path()));
1410 assert!(!searched_dirs.contains(&temp_dir.path()));
1411 }
1412 _ => panic!("Expected NotFound result, got: {:?}", result),
1413 }
1414 }
1415
1416 #[test]
1417 fn test_config_discovery_stops_at_cargo_toml() {
1418 let temp_dir = TempDir::new().unwrap();
1419
1420 let project_dir = temp_dir.path().join("project");
1422 let src_dir = project_dir.join("src");
1423 std::fs::create_dir_all(&src_dir).unwrap();
1424
1425 std::fs::write(project_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1427
1428 let outer_config = temp_dir.path().join("quickmark.toml");
1430 std::fs::write(&outer_config, "[linters.severity]\nheading-style = 'warn'").unwrap();
1431
1432 let file_path = src_dir.join("test.md");
1434 std::fs::write(&file_path, "# Test").unwrap();
1435
1436 let discovery = ConfigDiscovery::new();
1437 let result = discovery.find_config(&file_path);
1438
1439 match result {
1440 ConfigSearchResult::NotFound { searched_paths } => {
1441 let searched_dirs: Vec<_> =
1443 searched_paths.iter().filter_map(|p| p.parent()).collect();
1444 assert!(searched_dirs.contains(&src_dir.as_path()));
1445 assert!(searched_dirs.contains(&project_dir.as_path()));
1446 assert!(!searched_dirs.contains(&temp_dir.path()));
1447 }
1448 _ => panic!("Expected NotFound result, got: {:?}", result),
1449 }
1450 }
1451
1452 #[test]
1453 fn test_config_discovery_error() {
1454 let temp_dir = TempDir::new().unwrap();
1455
1456 let config_path = temp_dir.path().join("quickmark.toml");
1458 let invalid_config = "invalid toml content [[[";
1459 std::fs::write(&config_path, invalid_config).unwrap();
1460
1461 let file_path = temp_dir.path().join("test.md");
1463 std::fs::write(&file_path, "# Test").unwrap();
1464
1465 let discovery = ConfigDiscovery::new();
1466 let result = discovery.find_config(&file_path);
1467
1468 match result {
1469 ConfigSearchResult::Error { path, error } => {
1470 assert_eq!(path, config_path);
1471 assert!(error.contains("expected")); }
1473 _ => panic!("Expected Error result, got: {:?}", result),
1474 }
1475 }
1476
1477 #[test]
1478 fn test_discover_config_or_default_found() {
1479 let temp_dir = TempDir::new().unwrap();
1480
1481 let config_path = temp_dir.path().join("quickmark.toml");
1483 let config_content = r#"
1484 [linters.severity]
1485 heading-style = 'warn'
1486 "#;
1487 std::fs::write(&config_path, config_content).unwrap();
1488
1489 let file_path = temp_dir.path().join("test.md");
1491 std::fs::write(&file_path, "# Test").unwrap();
1492
1493 let result = discover_config_or_default(&file_path).unwrap();
1494 assert_eq!(
1495 *result.linters.severity.get("heading-style").unwrap(),
1496 RuleSeverity::Warning
1497 );
1498 }
1499
1500 #[test]
1501 fn test_discover_config_or_default_not_found() {
1502 let temp_dir = TempDir::new().unwrap();
1503 let file_path = temp_dir.path().join("test.md");
1504 std::fs::write(&file_path, "# Test").unwrap();
1505
1506 let result = discover_config_or_default(&file_path).unwrap();
1507 assert_eq!(
1509 *result.linters.severity.get("heading-style").unwrap(),
1510 RuleSeverity::Error
1511 );
1512 }
1513
1514 #[test]
1515 fn test_discover_config_with_workspace_or_default() {
1516 let temp_dir = TempDir::new().unwrap();
1517
1518 let workspace_dir = temp_dir.path().join("workspace");
1520 let project_dir = workspace_dir.join("project");
1521 std::fs::create_dir_all(&project_dir).unwrap();
1522
1523 let config_path = workspace_dir.join("quickmark.toml");
1525 let config_content = r#"
1526 [linters.severity]
1527 heading-style = 'off'
1528 "#;
1529 std::fs::write(&config_path, config_content).unwrap();
1530
1531 let file_path = project_dir.join("test.md");
1533 std::fs::write(&file_path, "# Test").unwrap();
1534
1535 let result =
1536 discover_config_with_workspace_or_default(&file_path, vec![workspace_dir.clone()])
1537 .unwrap();
1538
1539 assert_eq!(
1540 *result.linters.severity.get("heading-style").unwrap(),
1541 RuleSeverity::Off
1542 );
1543 }
1544
1545 #[test]
1546 fn test_should_stop_search_workspace_priority() {
1547 let temp_dir = TempDir::new().unwrap();
1548
1549 let workspace_dir = temp_dir.path().join("workspace");
1551 let git_dir = workspace_dir.join(".git");
1552 let project_dir = git_dir.join("project");
1553 std::fs::create_dir_all(&project_dir).unwrap();
1554
1555 let discovery = ConfigDiscovery::with_workspace_roots(vec![workspace_dir.clone()]);
1557
1558 assert!(discovery.should_stop_search(&workspace_dir));
1560 assert!(!discovery.should_stop_search(&git_dir));
1562 }
1563}