quickmark_core/config/
mod.rs

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    // Extract default severity if present, then remove it from the map
172    let default_severity = severities.remove("default").unwrap_or(RuleSeverity::Error);
173
174    // Remove invalid rules (keep only recognized rule aliases)
175    severities.retain(|key, _| rule_aliases.contains(key.as_str()));
176
177    // Apply default severity to all rules that don't have explicit configuration
178    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/// Result of searching for a configuration file
198#[derive(Debug, PartialEq, Clone)]
199pub enum ConfigSearchResult {
200    /// Configuration file found and successfully parsed
201    Found {
202        path: PathBuf,
203        config: Box<QuickmarkConfig>,
204    },
205    /// No configuration file found during search
206    NotFound { searched_paths: Vec<PathBuf> },
207    /// Configuration file found but failed to parse
208    Error { path: PathBuf, error: String },
209}
210
211/// Hierarchical config discovery with workspace root stopping point
212pub 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    /// Create a new ConfigDiscovery for CLI usage (no workspace roots)
224    pub fn new() -> Self {
225        Self {
226            workspace_roots: Vec::new(),
227        }
228    }
229
230    /// Create a new ConfigDiscovery for LSP usage with workspace roots
231    pub fn with_workspace_roots(roots: Vec<PathBuf>) -> Self {
232        Self {
233            workspace_roots: roots,
234        }
235    }
236
237    /// Find configuration file starting from the given file path
238    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            // Check if we should stop searching at this directory
278            if self.should_stop_search(current_dir) {
279                break;
280            }
281
282            // Move to parent directory
283            match current_dir.parent() {
284                Some(parent) => current_dir = parent,
285                None => break, // Reached filesystem root
286            }
287        }
288
289        ConfigSearchResult::NotFound { searched_paths }
290    }
291
292    /// Determine if search should stop at the current directory
293    fn should_stop_search(&self, dir: &Path) -> bool {
294        // 1. IDE Workspace Root (highest priority)
295        for workspace_root in &self.workspace_roots {
296            if dir == workspace_root.as_path() {
297                return true;
298            }
299        }
300
301        // 2. Git Repository Root
302        if dir.join(".git").exists() {
303            return true;
304        }
305
306        // 3. Common Project Root Markers
307        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
327/// Parse a TOML configuration string into a QuickmarkConfig
328pub 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
334/// Load configuration from QUICKMARK_CONFIG environment variable, path, or default
335pub fn config_from_env_path_or_default(path: &Path) -> Result<QuickmarkConfig> {
336    // First check if QUICKMARK_CONFIG environment variable is set
337    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    // Fallback to existing behavior - check for quickmark.toml in path
358    config_in_path_or_default(path)
359}
360
361/// Load configuration from a path, or return default if not found
362pub 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
375/// Convenience function that uses ConfigDiscovery to find config or return default
376pub 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
394/// Convenience function for LSP usage with workspace roots
395pub 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        // Create a local config in a temp directory
580        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        // Load config - should fall back to checking the provided path
591        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        // Should use default configuration
609        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        // Verify severities
754        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        // Verify heading-style settings
768        assert_eq!(
769            HeadingStyle::ATX,
770            parsed.linters.settings.heading_style.style
771        );
772
773        // Verify ul-style settings
774        use crate::rules::md004::UlStyle;
775        assert_eq!(UlStyle::Asterisk, parsed.linters.settings.ul_style.style);
776
777        // Verify ul-indent settings
778        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        // Verify trailing-spaces settings
783        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        // Verify line-length settings
794        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        // Verify single-h1 settings
807        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        // Verify ol-prefix settings
814        use crate::rules::md029::OlPrefixStyle;
815        assert_eq!(OlPrefixStyle::One, parsed.linters.settings.ol_prefix.style);
816
817        // Verify hard-tabs settings
818        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        // Verify multiple-blank-lines settings
826        assert_eq!(3, parsed.linters.settings.multiple_blank_lines.maximum);
827
828        // Verify headings-blanks settings
829        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        // Verify first-line-heading settings
839        assert!(parsed.linters.settings.first_line_heading.allow_preamble);
840
841        // Verify trailing-punctuation settings
842        assert_eq!(
843            ".,;:!?",
844            parsed.linters.settings.trailing_punctuation.punctuation
845        );
846
847        // Verify blockquote-spaces settings
848        assert!(!parsed.linters.settings.blockquote_spaces.list_items);
849
850        // Verify list-marker-space settings
851        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        // Verify fenced-code-blanks settings
857        assert!(!parsed.linters.settings.fenced_code_blanks.list_items);
858
859        // Verify inline-html settings
860        assert_eq!(
861            vec!["br", "img"],
862            parsed.linters.settings.inline_html.allowed_elements
863        );
864
865        // Verify hr-style settings
866        assert_eq!("asterisk", parsed.linters.settings.hr_style.style);
867
868        // Verify emphasis-as-heading settings
869        assert_eq!(
870            ".,;:!?",
871            parsed.linters.settings.emphasis_as_heading.punctuation
872        );
873
874        // Verify fenced-code-language settings
875        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        // Verify code-block-style settings
886        use crate::rules::md046::CodeBlockStyle;
887        assert_eq!(
888            CodeBlockStyle::Fenced,
889            parsed.linters.settings.code_block_style.style
890        );
891
892        // Verify code-fence-style settings
893        use crate::rules::md048::CodeFenceStyle;
894        assert_eq!(
895            CodeFenceStyle::Backtick,
896            parsed.linters.settings.code_fence_style.style
897        );
898
899        // Verify emphasis-style settings
900        use crate::rules::md049::EmphasisStyle;
901        assert_eq!(
902            EmphasisStyle::Asterisk,
903            parsed.linters.settings.emphasis_style.style
904        );
905
906        // Verify strong-style settings
907        use crate::rules::md050::StrongStyle;
908        assert_eq!(
909            StrongStyle::Underscore,
910            parsed.linters.settings.strong_style.style
911        );
912
913        // Verify multiple-headings settings
914        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        // Verify required-headings settings
924        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        // Verify proper-names settings
931        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        // Verify reference-links-images settings
939        assert_eq!(
940            vec!["x", "skip"],
941            parsed
942                .linters
943                .settings
944                .reference_links_images
945                .ignored_labels
946        );
947
948        // Verify link-image-reference-definitions settings
949        assert_eq!(
950            vec!["//", "skip"],
951            parsed
952                .linters
953                .settings
954                .link_image_reference_definitions
955                .ignored_definitions
956        );
957
958        // Verify link-image-style settings
959        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        // Verify table-pipe-style settings
967        use crate::rules::md055::TablePipeStyle;
968        assert_eq!(
969            TablePipeStyle::LeadingAndTrailing,
970            parsed.linters.settings.table_pipe_style.style
971        );
972
973        // Verify descriptive-link-text settings
974        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        // Verify all rules have Error severity (normalized default)
993        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        // Verify heading-style defaults
1011        assert_eq!(
1012            HeadingStyle::Consistent,
1013            parsed.linters.settings.heading_style.style
1014        );
1015
1016        // Verify ul-style defaults
1017        use crate::rules::md004::UlStyle;
1018        assert_eq!(UlStyle::Consistent, parsed.linters.settings.ul_style.style);
1019
1020        // Verify ul-indent defaults
1021        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        // Verify trailing-spaces defaults
1026        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        // Verify line-length defaults
1037        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        // Verify single-h1 defaults
1050        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        // Verify ol-prefix defaults
1057        use crate::rules::md029::OlPrefixStyle;
1058        assert_eq!(
1059            OlPrefixStyle::OneOrOrdered,
1060            parsed.linters.settings.ol_prefix.style
1061        );
1062
1063        // Verify multiple-blank-lines defaults
1064        assert_eq!(1, parsed.linters.settings.multiple_blank_lines.maximum);
1065
1066        // Verify hard-tabs defaults
1067        assert_eq!(1, parsed.linters.settings.hard_tabs.spaces_per_tab);
1068        assert!(parsed.linters.settings.hard_tabs.code_blocks);
1069
1070        // Verify first-line-heading defaults
1071        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        // Rules with explicit configuration should use that
1086        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        // Rules without explicit configuration should use default (Error)
1096        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        // Default should not appear in final severities map
1110        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        // Explicit rule should use Error
1124        assert_eq!(
1125            RuleSeverity::Error,
1126            *parsed.linters.severity.get("heading-style").unwrap()
1127        );
1128
1129        // All other rules should use default (Warning)
1130        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        // Default should not appear in final severities map
1144        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        // Explicit rules should use their configured severities
1159        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        // All other rules should be disabled (Off)
1169        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        // Default should not appear in final severities map
1183        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        // Valid explicit rules should be preserved
1200        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        // Invalid rules should be removed
1210        assert_eq!(None, parsed.linters.severity.get("invalid-rule"));
1211        assert_eq!(None, parsed.linters.severity.get("another-invalid"));
1212
1213        // Valid rules without explicit config should use default
1214        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        // Default should not appear in final severities map
1224        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        // Explicit rules should use their configured severities
1238        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        // Rules without explicit config should default to Error
1248        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                // Should have searched in temp_dir
1270                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        // Create a config file
1283        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        // Create a file in the same directory
1291        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        // Create nested directories: temp_dir/project/src/
1314        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        // Create config in project root
1319        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        // Create a file in src/
1327        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        // Create nested directories: temp_dir/repo/src/
1350        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        // Create .git directory to mark as repo root
1355        std::fs::create_dir(repo_dir.join(".git")).unwrap();
1356
1357        // Create config outside repo (should not be found)
1358        let outer_config = temp_dir.path().join("quickmark.toml");
1359        std::fs::write(&outer_config, "[linters.severity]\nheading-style = 'warn'").unwrap();
1360
1361        // Create file in src/
1362        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                // Should have searched in src/ and repo/ but not in temp_dir (stopped at .git)
1371                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        // Create nested directories: temp_dir/workspace/project/src/
1386        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        // Create config outside workspace (should not be found)
1392        let outer_config = temp_dir.path().join("quickmark.toml");
1393        std::fs::write(&outer_config, "[linters.severity]\nheading-style = 'warn'").unwrap();
1394
1395        // Create file in src/
1396        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                // Should have searched in src/, project/, and workspace/ but not temp_dir
1405                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        // Create nested directories: temp_dir/project/src/
1421        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        // Create Cargo.toml to mark as project root
1426        std::fs::write(project_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1427
1428        // Create config outside project (should not be found)
1429        let outer_config = temp_dir.path().join("quickmark.toml");
1430        std::fs::write(&outer_config, "[linters.severity]\nheading-style = 'warn'").unwrap();
1431
1432        // Create file in src/
1433        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                // Should have searched in src/ and project/ but not in temp_dir (stopped at Cargo.toml)
1442                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        // Create invalid config file
1457        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        // Create a file in the same directory
1462        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")); // TOML parse error
1472            }
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        // Create a config file
1482        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        // Create a file in the same directory
1490        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        // Should return default config with normalized severities
1508        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        // Create workspace directory
1519        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        // Create config in workspace
1524        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        // Create file in project
1532        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        // Create structure: temp_dir/workspace/.git/project/
1550        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        // ConfigDiscovery with workspace root should stop at workspace, not .git
1556        let discovery = ConfigDiscovery::with_workspace_roots(vec![workspace_dir.clone()]);
1557
1558        // Should stop at workspace (highest priority)
1559        assert!(discovery.should_stop_search(&workspace_dir));
1560        // Should not stop at .git when workspace root is set
1561        assert!(!discovery.should_stop_search(&git_dir));
1562    }
1563}