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 current_working_dir: Option<PathBuf>,
215}
216
217impl Default for ConfigDiscovery {
218 fn default() -> Self {
219 Self::new()
220 }
221}
222
223impl ConfigDiscovery {
224 pub fn new() -> Self {
226 let current_working_dir = std::env::current_dir().ok();
227 Self {
228 workspace_roots: Vec::new(),
229 current_working_dir,
230 }
231 }
232
233 pub fn with_workspace_roots(roots: Vec<PathBuf>) -> Self {
235 Self {
236 workspace_roots: roots,
237 current_working_dir: None,
238 }
239 }
240
241 pub fn find_config(&self, file_path: &Path) -> ConfigSearchResult {
243 let start_dir = if file_path.is_file() {
244 file_path.parent().unwrap_or(file_path)
245 } else {
246 file_path
247 };
248
249 let mut searched_paths = Vec::new();
250 let mut current_dir = start_dir;
251
252 loop {
253 let config_path = current_dir.join("quickmark.toml");
254 searched_paths.push(config_path.clone());
255
256 if config_path.is_file() {
257 match fs::read_to_string(&config_path) {
258 Ok(config_str) => match parse_toml_config(&config_str) {
259 Ok(config) => {
260 return ConfigSearchResult::Found {
261 path: config_path,
262 config: Box::new(config),
263 }
264 }
265 Err(e) => {
266 return ConfigSearchResult::Error {
267 path: config_path,
268 error: e.to_string(),
269 }
270 }
271 },
272 Err(e) => {
273 return ConfigSearchResult::Error {
274 path: config_path,
275 error: e.to_string(),
276 }
277 }
278 }
279 }
280
281 if self.should_stop_search(current_dir) {
283 break;
284 }
285
286 match current_dir.parent() {
288 Some(parent) => current_dir = parent,
289 None => break, }
291 }
292
293 ConfigSearchResult::NotFound { searched_paths }
294 }
295
296 fn should_stop_search(&self, dir: &Path) -> bool {
298 for workspace_root in &self.workspace_roots {
300 if dir == workspace_root.as_path() {
301 return true;
302 }
303 }
304
305 if let Some(cwd) = &self.current_working_dir {
307 if dir == cwd.as_path() {
308 return true;
309 }
310 }
311
312 if dir.join(".git").exists() {
314 return true;
315 }
316
317 false
318 }
319}
320
321pub fn parse_toml_config(config_str: &str) -> Result<QuickmarkConfig> {
323 let mut config: QuickmarkConfig = toml::from_str(config_str)?;
324 normalize_severities(&mut config.linters.severity);
325 Ok(config)
326}
327
328pub fn config_from_env_path_or_default(path: &Path) -> Result<QuickmarkConfig> {
330 if let Ok(env_config_path) = std::env::var("QUICKMARK_CONFIG") {
332 let env_config_file = Path::new(&env_config_path);
333 if env_config_file.is_file() {
334 match fs::read_to_string(env_config_file) {
335 Ok(config) => return parse_toml_config(&config),
336 Err(e) => {
337 eprintln!(
338 "Error loading config from QUICKMARK_CONFIG path {env_config_path}: {e}. Default config will be used."
339 );
340 return Ok(QuickmarkConfig::default_with_normalized_severities());
341 }
342 }
343 } else {
344 eprintln!(
345 "Config file was not found at QUICKMARK_CONFIG path {env_config_path}. Default config will be used."
346 );
347 return Ok(QuickmarkConfig::default_with_normalized_severities());
348 }
349 }
350
351 config_in_path_or_default(path)
353}
354
355pub fn config_in_path_or_default(path: &Path) -> Result<QuickmarkConfig> {
357 let config_file = path.join("quickmark.toml");
358 if config_file.is_file() {
359 let config = fs::read_to_string(config_file)?;
360 return parse_toml_config(&config);
361 }
362 eprintln!(
363 "Config file was not found at {}. Default config will be used.",
364 config_file.to_string_lossy()
365 );
366 Ok(QuickmarkConfig::default_with_normalized_severities())
367}
368
369pub fn discover_config_or_default(file_path: &Path) -> Result<QuickmarkConfig> {
371 let discovery = ConfigDiscovery::new();
372 match discovery.find_config(file_path) {
373 ConfigSearchResult::Found { config, .. } => Ok(*config),
374 ConfigSearchResult::NotFound { .. } => {
375 Ok(QuickmarkConfig::default_with_normalized_severities())
376 }
377 ConfigSearchResult::Error { path, error } => {
378 eprintln!(
379 "Error loading config from {}: {}. Default config will be used.",
380 path.to_string_lossy(),
381 error
382 );
383 Ok(QuickmarkConfig::default_with_normalized_severities())
384 }
385 }
386}
387
388pub fn discover_config_with_workspace_or_default(
390 file_path: &Path,
391 workspace_roots: Vec<PathBuf>,
392) -> Result<QuickmarkConfig> {
393 let discovery = ConfigDiscovery::with_workspace_roots(workspace_roots);
394 match discovery.find_config(file_path) {
395 ConfigSearchResult::Found { config, .. } => Ok(*config),
396 ConfigSearchResult::NotFound { .. } => {
397 Ok(QuickmarkConfig::default_with_normalized_severities())
398 }
399 ConfigSearchResult::Error { path, error } => {
400 eprintln!(
401 "Error loading config from {}: {}. Default config will be used.",
402 path.to_string_lossy(),
403 error
404 );
405 Ok(QuickmarkConfig::default_with_normalized_severities())
406 }
407 }
408}
409
410#[cfg(test)]
411mod test {
412 use std::collections::HashMap;
413 use std::path::Path;
414 use tempfile::TempDir;
415
416 use crate::config::{
417 config_from_env_path_or_default, discover_config_or_default,
418 discover_config_with_workspace_or_default, parse_toml_config, ConfigDiscovery,
419 ConfigSearchResult, HeadingStyle, LintersSettingsTable, LintersTable,
420 MD003HeadingStyleTable, MD004UlStyleTable, MD007UlIndentTable, MD009TrailingSpacesTable,
421 MD010HardTabsTable, MD012MultipleBlankLinesTable, MD013LineLengthTable,
422 MD022HeadingsBlanksTable, MD024MultipleHeadingsTable, MD025SingleH1Table,
423 MD026TrailingPunctuationTable, MD027BlockquoteSpacesTable, MD029OlPrefixTable,
424 MD030ListMarkerSpaceTable, MD031FencedCodeBlanksTable, MD033InlineHtmlTable,
425 MD035HrStyleTable, MD036EmphasisAsHeadingTable, MD040FencedCodeLanguageTable,
426 MD041FirstLineHeadingTable, MD043RequiredHeadingsTable, MD044ProperNamesTable,
427 MD046CodeBlockStyleTable, MD048CodeFenceStyleTable, MD049EmphasisStyleTable,
428 MD050StrongStyleTable, MD051LinkFragmentsTable, MD052ReferenceLinksImagesTable,
429 MD053LinkImageReferenceDefinitionsTable, MD054LinkImageStyleTable,
430 MD055TablePipeStyleTable, MD059DescriptiveLinkTextTable, RuleSeverity,
431 };
432
433 use super::{normalize_severities, QuickmarkConfig};
434
435 #[test]
436 pub fn test_normalize_severities() {
437 let mut severity: HashMap<String, RuleSeverity> = vec![
438 ("heading-style".to_string(), RuleSeverity::Error),
439 ("some-bullshit".to_string(), RuleSeverity::Warning),
440 ]
441 .into_iter()
442 .collect();
443
444 normalize_severities(&mut severity);
445
446 assert_eq!(
447 RuleSeverity::Error,
448 *severity.get("heading-increment").unwrap()
449 );
450 assert_eq!(RuleSeverity::Error, *severity.get("heading-style").unwrap());
451 assert_eq!(RuleSeverity::Error, *severity.get("list-indent").unwrap());
452 assert_eq!(
453 RuleSeverity::Error,
454 *severity.get("no-reversed-links").unwrap()
455 );
456 assert_eq!(None, severity.get("some-bullshit"));
457 }
458
459 #[test]
460 pub fn test_default_with_normalized_severities() {
461 let config = QuickmarkConfig::default_with_normalized_severities();
462 assert_eq!(
463 RuleSeverity::Error,
464 *config.linters.severity.get("heading-increment").unwrap()
465 );
466 assert_eq!(
467 RuleSeverity::Error,
468 *config.linters.severity.get("heading-style").unwrap()
469 );
470 assert_eq!(
471 RuleSeverity::Error,
472 *config.linters.severity.get("list-indent").unwrap()
473 );
474 assert_eq!(
475 RuleSeverity::Error,
476 *config.linters.severity.get("no-reversed-links").unwrap()
477 );
478 assert_eq!(
479 HeadingStyle::Consistent,
480 config.linters.settings.heading_style.style
481 );
482 }
483
484 #[test]
485 pub fn test_new_config() {
486 let severity: HashMap<String, RuleSeverity> = vec![
487 ("heading-increment".to_string(), RuleSeverity::Warning),
488 ("heading-style".to_string(), RuleSeverity::Off),
489 ]
490 .into_iter()
491 .collect();
492
493 let config = QuickmarkConfig::new(LintersTable {
494 severity,
495 settings: LintersSettingsTable {
496 heading_style: MD003HeadingStyleTable {
497 style: HeadingStyle::ATX,
498 },
499 ul_style: MD004UlStyleTable::default(),
500 ol_prefix: MD029OlPrefixTable::default(),
501 list_marker_space: MD030ListMarkerSpaceTable::default(),
502 ul_indent: MD007UlIndentTable::default(),
503 trailing_spaces: MD009TrailingSpacesTable::default(),
504 hard_tabs: MD010HardTabsTable::default(),
505 multiple_blank_lines: MD012MultipleBlankLinesTable::default(),
506 line_length: MD013LineLengthTable::default(),
507 headings_blanks: MD022HeadingsBlanksTable::default(),
508 single_h1: MD025SingleH1Table::default(),
509 first_line_heading: MD041FirstLineHeadingTable::default(),
510 trailing_punctuation: MD026TrailingPunctuationTable::default(),
511 blockquote_spaces: MD027BlockquoteSpacesTable::default(),
512 fenced_code_blanks: MD031FencedCodeBlanksTable::default(),
513 inline_html: MD033InlineHtmlTable::default(),
514 hr_style: MD035HrStyleTable::default(),
515 emphasis_as_heading: MD036EmphasisAsHeadingTable::default(),
516 fenced_code_language: MD040FencedCodeLanguageTable::default(),
517 code_block_style: MD046CodeBlockStyleTable::default(),
518 code_fence_style: MD048CodeFenceStyleTable::default(),
519 emphasis_style: MD049EmphasisStyleTable::default(),
520 strong_style: MD050StrongStyleTable::default(),
521 multiple_headings: MD024MultipleHeadingsTable::default(),
522 required_headings: MD043RequiredHeadingsTable::default(),
523 proper_names: MD044ProperNamesTable::default(),
524 link_fragments: MD051LinkFragmentsTable::default(),
525 reference_links_images: MD052ReferenceLinksImagesTable::default(),
526 link_image_reference_definitions: MD053LinkImageReferenceDefinitionsTable::default(
527 ),
528 link_image_style: MD054LinkImageStyleTable::default(),
529 table_pipe_style: MD055TablePipeStyleTable::default(),
530 descriptive_link_text: MD059DescriptiveLinkTextTable::default(),
531 },
532 });
533
534 assert_eq!(
535 RuleSeverity::Warning,
536 *config.linters.severity.get("heading-increment").unwrap()
537 );
538 assert_eq!(
539 RuleSeverity::Off,
540 *config.linters.severity.get("heading-style").unwrap()
541 );
542 assert_eq!(
543 HeadingStyle::ATX,
544 config.linters.settings.heading_style.style
545 );
546 }
547
548 #[test]
549 fn test_parse_toml_config_with_invalid_rules() {
550 let config_str = r#"
551 [linters.severity]
552 heading-style = 'err'
553 some-invalid-rule = 'warn'
554
555 [linters.settings.heading-style]
556 style = 'atx'
557 "#;
558
559 let parsed = parse_toml_config(config_str).unwrap();
560 assert_eq!(
561 RuleSeverity::Error,
562 *parsed.linters.severity.get("heading-increment").unwrap()
563 );
564 assert_eq!(
565 RuleSeverity::Error,
566 *parsed.linters.severity.get("heading-style").unwrap()
567 );
568 assert_eq!(None, parsed.linters.severity.get("some-invalid-rule"));
569 }
570
571 #[test]
572 fn test_config_from_env_fallback_to_local() {
573 let temp_dir = tempfile::tempdir().unwrap();
575 let config_path = temp_dir.path().join("quickmark.toml");
576 let config_content = r#"
577 [linters.severity]
578 heading-increment = 'err'
579 heading-style = 'off'
580 "#;
581
582 std::fs::write(&config_path, config_content).unwrap();
583
584 let config = config_from_env_path_or_default(temp_dir.path()).unwrap();
586
587 assert_eq!(
588 RuleSeverity::Error,
589 *config.linters.severity.get("heading-increment").unwrap()
590 );
591 assert_eq!(
592 RuleSeverity::Off,
593 *config.linters.severity.get("heading-style").unwrap()
594 );
595 }
596
597 #[test]
598 fn test_config_from_env_default_when_no_config() {
599 let dummy_path = Path::new("/tmp");
600 let config = config_from_env_path_or_default(dummy_path).unwrap();
601
602 assert_eq!(
604 RuleSeverity::Error,
605 *config.linters.severity.get("heading-increment").unwrap()
606 );
607 assert_eq!(
608 RuleSeverity::Error,
609 *config.linters.severity.get("heading-style").unwrap()
610 );
611 }
612
613 #[test]
614 fn test_parse_full_config_with_custom_parameters() {
615 let config_str = r#"
616 [linters.severity]
617 heading-style = 'warn'
618 ul-style = 'off'
619 line-length = 'err'
620
621 [linters.settings.heading-style]
622 style = 'atx'
623
624 [linters.settings.ul-style]
625 style = 'asterisk'
626
627 [linters.settings.ol-prefix]
628 style = 'one'
629
630 [linters.settings.ul-indent]
631 indent = 4
632 start_indent = 3
633 start_indented = true
634
635 [linters.settings.no-trailing-spaces]
636 br_spaces = 3
637 list_item_empty_lines = true
638 strict = true
639
640 [linters.settings.no-hard-tabs]
641 code_blocks = false
642 ignore_code_languages = ["python", "go"]
643 spaces_per_tab = 8
644
645 [linters.settings.no-multiple-blanks]
646 maximum = 3
647
648 [linters.settings.line-length]
649 line_length = 120
650 code_block_line_length = 100
651 heading_line_length = 90
652 code_blocks = false
653 headings = false
654 tables = false
655 strict = true
656 stern = true
657
658 [linters.settings.blanks-around-headings]
659 lines_above = [2, 1, 1, 1, 1, 1]
660 lines_below = [2, 1, 1, 1, 1, 1]
661
662 [linters.settings.single-h1]
663 level = 2
664 front_matter_title = "^title:"
665
666 [linters.settings.first-line-heading]
667 allow_preamble = true
668
669 [linters.settings.no-trailing-punctuation]
670 punctuation = ".,;:!?"
671
672 [linters.settings.no-multiple-space-blockquote]
673 list_items = false
674
675 [linters.settings.list-marker-space]
676 ul_single = 2
677 ol_single = 3
678 ul_multi = 3
679 ol_multi = 4
680
681 [linters.settings.blanks-around-fences]
682 list_items = false
683
684 [linters.settings.no-inline-html]
685 allowed_elements = ["br", "img"]
686
687 [linters.settings.hr-style]
688 style = "asterisk"
689
690 [linters.settings.no-emphasis-as-heading]
691 punctuation = ".,;:!?"
692
693 [linters.settings.fenced-code-language]
694 allowed_languages = ["rust", "python"]
695 language_only = true
696
697 [linters.settings.code-block-style]
698 style = 'fenced'
699
700 [linters.settings.code-fence-style]
701 style = 'backtick'
702
703 [linters.settings.emphasis-style]
704 style = 'asterisk'
705
706 [linters.settings.strong-style]
707 style = 'underscore'
708
709 [linters.settings.no-duplicate-heading]
710 siblings_only = false
711 allow_different_nesting = false
712
713 [linters.settings.required-headings]
714 headings = ["Introduction", "Usage", "Examples"]
715 match_case = true
716
717 [linters.settings.proper-names]
718 names = ["JavaScript", "GitHub", "API"]
719 code_blocks = false
720 html_elements = false
721
722 [linters.settings.link-fragments]
723
724 [linters.settings.reference-links-images]
725 ignored_labels = ["x", "skip"]
726
727 [linters.settings.link-image-reference-definitions]
728 ignored_definitions = ["//", "skip"]
729
730 [linters.settings.link-image-style]
731 autolink = false
732 inline = true
733 full = true
734 collapsed = false
735 shortcut = false
736 url_inline = false
737
738 [linters.settings.table-pipe-style]
739 style = 'leading_and_trailing'
740
741 [linters.settings.descriptive-link-text]
742 prohibited_texts = ["click here", "read more", "see here"]
743 "#;
744
745 let parsed = parse_toml_config(config_str).unwrap();
746
747 assert_eq!(
749 RuleSeverity::Warning,
750 *parsed.linters.severity.get("heading-style").unwrap()
751 );
752 assert_eq!(
753 RuleSeverity::Off,
754 *parsed.linters.severity.get("ul-style").unwrap()
755 );
756 assert_eq!(
757 RuleSeverity::Error,
758 *parsed.linters.severity.get("line-length").unwrap()
759 );
760
761 assert_eq!(
763 HeadingStyle::ATX,
764 parsed.linters.settings.heading_style.style
765 );
766
767 use crate::rules::md004::UlStyle;
769 assert_eq!(UlStyle::Asterisk, parsed.linters.settings.ul_style.style);
770
771 assert_eq!(4, parsed.linters.settings.ul_indent.indent);
773 assert_eq!(3, parsed.linters.settings.ul_indent.start_indent);
774 assert!(parsed.linters.settings.ul_indent.start_indented);
775
776 assert_eq!(3, parsed.linters.settings.trailing_spaces.br_spaces);
778 assert!(
779 parsed
780 .linters
781 .settings
782 .trailing_spaces
783 .list_item_empty_lines
784 );
785 assert!(parsed.linters.settings.trailing_spaces.strict);
786
787 assert_eq!(120, parsed.linters.settings.line_length.line_length);
789 assert_eq!(
790 100,
791 parsed.linters.settings.line_length.code_block_line_length
792 );
793 assert_eq!(90, parsed.linters.settings.line_length.heading_line_length);
794 assert!(!parsed.linters.settings.line_length.code_blocks);
795 assert!(!parsed.linters.settings.line_length.headings);
796 assert!(!parsed.linters.settings.line_length.tables);
797 assert!(parsed.linters.settings.line_length.strict);
798 assert!(parsed.linters.settings.line_length.stern);
799
800 assert_eq!(2, parsed.linters.settings.single_h1.level);
802 assert_eq!(
803 "^title:",
804 parsed.linters.settings.single_h1.front_matter_title
805 );
806
807 use crate::rules::md029::OlPrefixStyle;
809 assert_eq!(OlPrefixStyle::One, parsed.linters.settings.ol_prefix.style);
810
811 assert!(!parsed.linters.settings.hard_tabs.code_blocks);
813 assert_eq!(
814 vec!["python", "go"],
815 parsed.linters.settings.hard_tabs.ignore_code_languages
816 );
817 assert_eq!(8, parsed.linters.settings.hard_tabs.spaces_per_tab);
818
819 assert_eq!(3, parsed.linters.settings.multiple_blank_lines.maximum);
821
822 assert_eq!(
824 vec![2, 1, 1, 1, 1, 1],
825 parsed.linters.settings.headings_blanks.lines_above
826 );
827 assert_eq!(
828 vec![2, 1, 1, 1, 1, 1],
829 parsed.linters.settings.headings_blanks.lines_below
830 );
831
832 assert!(parsed.linters.settings.first_line_heading.allow_preamble);
834
835 assert_eq!(
837 ".,;:!?",
838 parsed.linters.settings.trailing_punctuation.punctuation
839 );
840
841 assert!(!parsed.linters.settings.blockquote_spaces.list_items);
843
844 assert_eq!(2, parsed.linters.settings.list_marker_space.ul_single);
846 assert_eq!(3, parsed.linters.settings.list_marker_space.ol_single);
847 assert_eq!(3, parsed.linters.settings.list_marker_space.ul_multi);
848 assert_eq!(4, parsed.linters.settings.list_marker_space.ol_multi);
849
850 assert!(!parsed.linters.settings.fenced_code_blanks.list_items);
852
853 assert_eq!(
855 vec!["br", "img"],
856 parsed.linters.settings.inline_html.allowed_elements
857 );
858
859 assert_eq!("asterisk", parsed.linters.settings.hr_style.style);
861
862 assert_eq!(
864 ".,;:!?",
865 parsed.linters.settings.emphasis_as_heading.punctuation
866 );
867
868 assert_eq!(
870 vec!["rust", "python"],
871 parsed
872 .linters
873 .settings
874 .fenced_code_language
875 .allowed_languages
876 );
877 assert!(parsed.linters.settings.fenced_code_language.language_only);
878
879 use crate::rules::md046::CodeBlockStyle;
881 assert_eq!(
882 CodeBlockStyle::Fenced,
883 parsed.linters.settings.code_block_style.style
884 );
885
886 use crate::rules::md048::CodeFenceStyle;
888 assert_eq!(
889 CodeFenceStyle::Backtick,
890 parsed.linters.settings.code_fence_style.style
891 );
892
893 use crate::rules::md049::EmphasisStyle;
895 assert_eq!(
896 EmphasisStyle::Asterisk,
897 parsed.linters.settings.emphasis_style.style
898 );
899
900 use crate::rules::md050::StrongStyle;
902 assert_eq!(
903 StrongStyle::Underscore,
904 parsed.linters.settings.strong_style.style
905 );
906
907 assert!(!parsed.linters.settings.multiple_headings.siblings_only);
909 assert!(
910 !parsed
911 .linters
912 .settings
913 .multiple_headings
914 .allow_different_nesting
915 );
916
917 assert_eq!(
919 vec!["Introduction", "Usage", "Examples"],
920 parsed.linters.settings.required_headings.headings
921 );
922 assert!(parsed.linters.settings.required_headings.match_case);
923
924 assert_eq!(
926 vec!["JavaScript", "GitHub", "API"],
927 parsed.linters.settings.proper_names.names
928 );
929 assert!(!parsed.linters.settings.proper_names.code_blocks);
930 assert!(!parsed.linters.settings.proper_names.html_elements);
931
932 assert_eq!(
934 vec!["x", "skip"],
935 parsed
936 .linters
937 .settings
938 .reference_links_images
939 .ignored_labels
940 );
941
942 assert_eq!(
944 vec!["//", "skip"],
945 parsed
946 .linters
947 .settings
948 .link_image_reference_definitions
949 .ignored_definitions
950 );
951
952 assert!(!parsed.linters.settings.link_image_style.autolink);
954 assert!(parsed.linters.settings.link_image_style.inline);
955 assert!(parsed.linters.settings.link_image_style.full);
956 assert!(!parsed.linters.settings.link_image_style.collapsed);
957 assert!(!parsed.linters.settings.link_image_style.shortcut);
958 assert!(!parsed.linters.settings.link_image_style.url_inline);
959
960 use crate::rules::md055::TablePipeStyle;
962 assert_eq!(
963 TablePipeStyle::LeadingAndTrailing,
964 parsed.linters.settings.table_pipe_style.style
965 );
966
967 assert_eq!(
969 vec!["click here", "read more", "see here"],
970 parsed
971 .linters
972 .settings
973 .descriptive_link_text
974 .prohibited_texts
975 );
976 }
977
978 #[test]
979 fn test_parse_empty_config_uses_defaults() {
980 let config_str = r#"
981 # Empty config - should use all defaults
982 "#;
983
984 let parsed = parse_toml_config(config_str).unwrap();
985
986 assert_eq!(
988 RuleSeverity::Error,
989 *parsed.linters.severity.get("heading-style").unwrap()
990 );
991 assert_eq!(
992 RuleSeverity::Error,
993 *parsed.linters.severity.get("ul-style").unwrap()
994 );
995 assert_eq!(
996 RuleSeverity::Error,
997 *parsed.linters.severity.get("line-length").unwrap()
998 );
999 assert_eq!(
1000 RuleSeverity::Error,
1001 *parsed.linters.severity.get("ul-indent").unwrap()
1002 );
1003
1004 assert_eq!(
1006 HeadingStyle::Consistent,
1007 parsed.linters.settings.heading_style.style
1008 );
1009
1010 use crate::rules::md004::UlStyle;
1012 assert_eq!(UlStyle::Consistent, parsed.linters.settings.ul_style.style);
1013
1014 assert_eq!(2, parsed.linters.settings.ul_indent.indent);
1016 assert_eq!(2, parsed.linters.settings.ul_indent.start_indent);
1017 assert!(!parsed.linters.settings.ul_indent.start_indented);
1018
1019 assert_eq!(2, parsed.linters.settings.trailing_spaces.br_spaces);
1021 assert!(
1022 !parsed
1023 .linters
1024 .settings
1025 .trailing_spaces
1026 .list_item_empty_lines
1027 );
1028 assert!(!parsed.linters.settings.trailing_spaces.strict);
1029
1030 assert_eq!(80, parsed.linters.settings.line_length.line_length);
1032 assert_eq!(
1033 80,
1034 parsed.linters.settings.line_length.code_block_line_length
1035 );
1036 assert_eq!(80, parsed.linters.settings.line_length.heading_line_length);
1037 assert!(parsed.linters.settings.line_length.code_blocks);
1038 assert!(parsed.linters.settings.line_length.headings);
1039 assert!(parsed.linters.settings.line_length.tables);
1040 assert!(!parsed.linters.settings.line_length.strict);
1041 assert!(!parsed.linters.settings.line_length.stern);
1042
1043 assert_eq!(1, parsed.linters.settings.single_h1.level);
1045 assert_eq!(
1046 r"^\s*title\s*[:=]",
1047 parsed.linters.settings.single_h1.front_matter_title
1048 );
1049
1050 use crate::rules::md029::OlPrefixStyle;
1052 assert_eq!(
1053 OlPrefixStyle::OneOrOrdered,
1054 parsed.linters.settings.ol_prefix.style
1055 );
1056
1057 assert_eq!(1, parsed.linters.settings.multiple_blank_lines.maximum);
1059
1060 assert_eq!(1, parsed.linters.settings.hard_tabs.spaces_per_tab);
1062 assert!(parsed.linters.settings.hard_tabs.code_blocks);
1063
1064 assert!(!parsed.linters.settings.first_line_heading.allow_preamble);
1066 }
1067
1068 #[test]
1069 fn test_default_severity_error() {
1070 let config_str = r#"
1071 [linters.severity]
1072 default = "err"
1073 heading-style = "warn"
1074 ul-style = "off"
1075 "#;
1076
1077 let parsed = parse_toml_config(config_str).unwrap();
1078
1079 assert_eq!(
1081 RuleSeverity::Warning,
1082 *parsed.linters.severity.get("heading-style").unwrap()
1083 );
1084 assert_eq!(
1085 RuleSeverity::Off,
1086 *parsed.linters.severity.get("ul-style").unwrap()
1087 );
1088
1089 assert_eq!(
1091 RuleSeverity::Error,
1092 *parsed.linters.severity.get("line-length").unwrap()
1093 );
1094 assert_eq!(
1095 RuleSeverity::Error,
1096 *parsed.linters.severity.get("ul-indent").unwrap()
1097 );
1098 assert_eq!(
1099 RuleSeverity::Error,
1100 *parsed.linters.severity.get("no-hard-tabs").unwrap()
1101 );
1102
1103 assert_eq!(None, parsed.linters.severity.get("default"));
1105 }
1106
1107 #[test]
1108 fn test_default_severity_warning() {
1109 let config_str = r#"
1110 [linters.severity]
1111 default = "warn"
1112 heading-style = "err"
1113 "#;
1114
1115 let parsed = parse_toml_config(config_str).unwrap();
1116
1117 assert_eq!(
1119 RuleSeverity::Error,
1120 *parsed.linters.severity.get("heading-style").unwrap()
1121 );
1122
1123 assert_eq!(
1125 RuleSeverity::Warning,
1126 *parsed.linters.severity.get("ul-style").unwrap()
1127 );
1128 assert_eq!(
1129 RuleSeverity::Warning,
1130 *parsed.linters.severity.get("line-length").unwrap()
1131 );
1132 assert_eq!(
1133 RuleSeverity::Warning,
1134 *parsed.linters.severity.get("ul-indent").unwrap()
1135 );
1136
1137 assert_eq!(None, parsed.linters.severity.get("default"));
1139 }
1140
1141 #[test]
1142 fn test_default_severity_off() {
1143 let config_str = r#"
1144 [linters.severity]
1145 default = "off"
1146 heading-style = "err"
1147 line-length = "warn"
1148 "#;
1149
1150 let parsed = parse_toml_config(config_str).unwrap();
1151
1152 assert_eq!(
1154 RuleSeverity::Error,
1155 *parsed.linters.severity.get("heading-style").unwrap()
1156 );
1157 assert_eq!(
1158 RuleSeverity::Warning,
1159 *parsed.linters.severity.get("line-length").unwrap()
1160 );
1161
1162 assert_eq!(
1164 RuleSeverity::Off,
1165 *parsed.linters.severity.get("ul-style").unwrap()
1166 );
1167 assert_eq!(
1168 RuleSeverity::Off,
1169 *parsed.linters.severity.get("ul-indent").unwrap()
1170 );
1171 assert_eq!(
1172 RuleSeverity::Off,
1173 *parsed.linters.severity.get("no-hard-tabs").unwrap()
1174 );
1175
1176 assert_eq!(None, parsed.linters.severity.get("default"));
1178 }
1179
1180 #[test]
1181 fn test_default_severity_with_invalid_rules() {
1182 let config_str = r#"
1183 [linters.severity]
1184 default = "warn"
1185 heading-style = "err"
1186 invalid-rule = "off"
1187 another-invalid = "warn"
1188 ul-style = "off"
1189 "#;
1190
1191 let parsed = parse_toml_config(config_str).unwrap();
1192
1193 assert_eq!(
1195 RuleSeverity::Error,
1196 *parsed.linters.severity.get("heading-style").unwrap()
1197 );
1198 assert_eq!(
1199 RuleSeverity::Off,
1200 *parsed.linters.severity.get("ul-style").unwrap()
1201 );
1202
1203 assert_eq!(None, parsed.linters.severity.get("invalid-rule"));
1205 assert_eq!(None, parsed.linters.severity.get("another-invalid"));
1206
1207 assert_eq!(
1209 RuleSeverity::Warning,
1210 *parsed.linters.severity.get("line-length").unwrap()
1211 );
1212 assert_eq!(
1213 RuleSeverity::Warning,
1214 *parsed.linters.severity.get("ul-indent").unwrap()
1215 );
1216
1217 assert_eq!(None, parsed.linters.severity.get("default"));
1219 }
1220
1221 #[test]
1222 fn test_no_default_uses_error() {
1223 let config_str = r#"
1224 [linters.severity]
1225 heading-style = "warn"
1226 ul-style = "off"
1227 "#;
1228
1229 let parsed = parse_toml_config(config_str).unwrap();
1230
1231 assert_eq!(
1233 RuleSeverity::Warning,
1234 *parsed.linters.severity.get("heading-style").unwrap()
1235 );
1236 assert_eq!(
1237 RuleSeverity::Off,
1238 *parsed.linters.severity.get("ul-style").unwrap()
1239 );
1240
1241 assert_eq!(
1243 RuleSeverity::Error,
1244 *parsed.linters.severity.get("line-length").unwrap()
1245 );
1246 assert_eq!(
1247 RuleSeverity::Error,
1248 *parsed.linters.severity.get("ul-indent").unwrap()
1249 );
1250 }
1251
1252 #[test]
1253 fn test_config_discovery_not_found() {
1254 let temp_dir = TempDir::new().unwrap();
1255 let file_path = temp_dir.path().join("test.md");
1256
1257 let discovery = ConfigDiscovery::new();
1258 let result = discovery.find_config(&file_path);
1259
1260 match result {
1261 ConfigSearchResult::NotFound { searched_paths } => {
1262 assert!(!searched_paths.is_empty());
1263 assert!(searched_paths
1265 .iter()
1266 .any(|p| p.parent() == Some(temp_dir.path())));
1267 }
1268 _ => panic!("Expected NotFound result"),
1269 }
1270 }
1271
1272 #[test]
1273 fn test_config_discovery_found() {
1274 let temp_dir = TempDir::new().unwrap();
1275
1276 let config_path = temp_dir.path().join("quickmark.toml");
1278 let config_content = r#"
1279 [linters.severity]
1280 heading-style = 'warn'
1281 "#;
1282 std::fs::write(&config_path, config_content).unwrap();
1283
1284 let file_path = temp_dir.path().join("test.md");
1286 std::fs::write(&file_path, "# Test").unwrap();
1287
1288 let discovery = ConfigDiscovery::new();
1289 let result = discovery.find_config(&file_path);
1290
1291 match result {
1292 ConfigSearchResult::Found { path, config } => {
1293 assert_eq!(path, config_path);
1294 assert_eq!(
1295 *config.linters.severity.get("heading-style").unwrap(),
1296 RuleSeverity::Warning
1297 );
1298 }
1299 _ => panic!("Expected Found result, got: {:?}", result),
1300 }
1301 }
1302
1303 #[test]
1304 fn test_config_discovery_hierarchical_search() {
1305 let temp_dir = TempDir::new().unwrap();
1306
1307 let project_dir = temp_dir.path().join("project");
1309 let src_dir = project_dir.join("src");
1310 std::fs::create_dir_all(&src_dir).unwrap();
1311
1312 let config_path = project_dir.join("quickmark.toml");
1314 let config_content = r#"
1315 [linters.severity]
1316 heading-style = 'off'
1317 "#;
1318 std::fs::write(&config_path, config_content).unwrap();
1319
1320 let file_path = src_dir.join("test.md");
1322 std::fs::write(&file_path, "# Test").unwrap();
1323
1324 let discovery = ConfigDiscovery::new();
1325 let result = discovery.find_config(&file_path);
1326
1327 match result {
1328 ConfigSearchResult::Found { path, config } => {
1329 assert_eq!(path, config_path);
1330 assert_eq!(
1331 *config.linters.severity.get("heading-style").unwrap(),
1332 RuleSeverity::Off
1333 );
1334 }
1335 _ => panic!("Expected Found result, got: {:?}", result),
1336 }
1337 }
1338
1339 #[test]
1340 fn test_config_discovery_stops_at_git_root() {
1341 let temp_dir = TempDir::new().unwrap();
1342
1343 let repo_dir = temp_dir.path().join("repo");
1345 let src_dir = repo_dir.join("src");
1346 std::fs::create_dir_all(&src_dir).unwrap();
1347
1348 std::fs::create_dir(repo_dir.join(".git")).unwrap();
1350
1351 let outer_config = temp_dir.path().join("quickmark.toml");
1353 std::fs::write(&outer_config, "[linters.severity]\nheading-style = 'warn'").unwrap();
1354
1355 let file_path = src_dir.join("test.md");
1357 std::fs::write(&file_path, "# Test").unwrap();
1358
1359 let discovery = ConfigDiscovery::new();
1360 let result = discovery.find_config(&file_path);
1361
1362 match result {
1363 ConfigSearchResult::NotFound { searched_paths } => {
1364 let searched_dirs: Vec<_> =
1366 searched_paths.iter().filter_map(|p| p.parent()).collect();
1367 assert!(searched_dirs.contains(&src_dir.as_path()));
1368 assert!(searched_dirs.contains(&repo_dir.as_path()));
1369 assert!(!searched_dirs.contains(&temp_dir.path()));
1370 }
1371 _ => panic!("Expected NotFound result, got: {:?}", result),
1372 }
1373 }
1374
1375 #[test]
1376 fn test_config_discovery_stops_at_workspace_root() {
1377 let temp_dir = TempDir::new().unwrap();
1378
1379 let workspace_dir = temp_dir.path().join("workspace");
1381 let project_dir = workspace_dir.join("project");
1382 let src_dir = project_dir.join("src");
1383 std::fs::create_dir_all(&src_dir).unwrap();
1384
1385 let outer_config = temp_dir.path().join("quickmark.toml");
1387 std::fs::write(&outer_config, "[linters.severity]\nheading-style = 'warn'").unwrap();
1388
1389 let file_path = src_dir.join("test.md");
1391 std::fs::write(&file_path, "# Test").unwrap();
1392
1393 let discovery = ConfigDiscovery::with_workspace_roots(vec![workspace_dir.clone()]);
1394 let result = discovery.find_config(&file_path);
1395
1396 match result {
1397 ConfigSearchResult::NotFound { searched_paths } => {
1398 let searched_dirs: Vec<_> =
1400 searched_paths.iter().filter_map(|p| p.parent()).collect();
1401 assert!(searched_dirs.contains(&src_dir.as_path()));
1402 assert!(searched_dirs.contains(&project_dir.as_path()));
1403 assert!(searched_dirs.contains(&workspace_dir.as_path()));
1404 assert!(!searched_dirs.contains(&temp_dir.path()));
1405 }
1406 _ => panic!("Expected NotFound result, got: {:?}", result),
1407 }
1408 }
1409
1410 #[test]
1411 fn test_config_discovery_error() {
1412 let temp_dir = TempDir::new().unwrap();
1413
1414 let config_path = temp_dir.path().join("quickmark.toml");
1416 let invalid_config = "invalid toml content [[[";
1417 std::fs::write(&config_path, invalid_config).unwrap();
1418
1419 let file_path = temp_dir.path().join("test.md");
1421 std::fs::write(&file_path, "# Test").unwrap();
1422
1423 let discovery = ConfigDiscovery::new();
1424 let result = discovery.find_config(&file_path);
1425
1426 match result {
1427 ConfigSearchResult::Error { path, error } => {
1428 assert_eq!(path, config_path);
1429 assert!(error.contains("expected")); }
1431 _ => panic!("Expected Error result, got: {:?}", result),
1432 }
1433 }
1434
1435 #[test]
1436 fn test_discover_config_or_default_found() {
1437 let temp_dir = TempDir::new().unwrap();
1438
1439 let config_path = temp_dir.path().join("quickmark.toml");
1441 let config_content = r#"
1442 [linters.severity]
1443 heading-style = 'warn'
1444 "#;
1445 std::fs::write(&config_path, config_content).unwrap();
1446
1447 let file_path = temp_dir.path().join("test.md");
1449 std::fs::write(&file_path, "# Test").unwrap();
1450
1451 let result = discover_config_or_default(&file_path).unwrap();
1452 assert_eq!(
1453 *result.linters.severity.get("heading-style").unwrap(),
1454 RuleSeverity::Warning
1455 );
1456 }
1457
1458 #[test]
1459 fn test_discover_config_or_default_not_found() {
1460 let temp_dir = TempDir::new().unwrap();
1461 let file_path = temp_dir.path().join("test.md");
1462 std::fs::write(&file_path, "# Test").unwrap();
1463
1464 let result = discover_config_or_default(&file_path).unwrap();
1465 assert_eq!(
1467 *result.linters.severity.get("heading-style").unwrap(),
1468 RuleSeverity::Error
1469 );
1470 }
1471
1472 #[test]
1473 fn test_discover_config_with_workspace_or_default() {
1474 let temp_dir = TempDir::new().unwrap();
1475
1476 let workspace_dir = temp_dir.path().join("workspace");
1478 let project_dir = workspace_dir.join("project");
1479 std::fs::create_dir_all(&project_dir).unwrap();
1480
1481 let config_path = workspace_dir.join("quickmark.toml");
1483 let config_content = r#"
1484 [linters.severity]
1485 heading-style = 'off'
1486 "#;
1487 std::fs::write(&config_path, config_content).unwrap();
1488
1489 let file_path = project_dir.join("test.md");
1491 std::fs::write(&file_path, "# Test").unwrap();
1492
1493 let result =
1494 discover_config_with_workspace_or_default(&file_path, vec![workspace_dir.clone()])
1495 .unwrap();
1496
1497 assert_eq!(
1498 *result.linters.severity.get("heading-style").unwrap(),
1499 RuleSeverity::Off
1500 );
1501 }
1502
1503 #[test]
1504 fn test_should_stop_search_workspace_priority() {
1505 let temp_dir = TempDir::new().unwrap();
1506
1507 let workspace_dir = temp_dir.path().join("workspace");
1509 let git_dir = workspace_dir.join(".git");
1510 let project_dir = git_dir.join("project");
1511 std::fs::create_dir_all(&project_dir).unwrap();
1512
1513 let discovery = ConfigDiscovery::with_workspace_roots(vec![workspace_dir.clone()]);
1515
1516 assert!(discovery.should_stop_search(&workspace_dir));
1518 assert!(!discovery.should_stop_search(&git_dir));
1520 }
1521}