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    current_working_dir: Option<PathBuf>,
215}
216
217impl Default for ConfigDiscovery {
218    fn default() -> Self {
219        Self::new()
220    }
221}
222
223impl ConfigDiscovery {
224    /// Create a new ConfigDiscovery for CLI usage (uses current working directory as boundary)
225    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    /// Create a new ConfigDiscovery for LSP usage with workspace roots
234    pub fn with_workspace_roots(roots: Vec<PathBuf>) -> Self {
235        Self {
236            workspace_roots: roots,
237            current_working_dir: None,
238        }
239    }
240
241    /// Find configuration file starting from the given file path
242    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            // Check if we should stop searching at this directory
282            if self.should_stop_search(current_dir) {
283                break;
284            }
285
286            // Move to parent directory
287            match current_dir.parent() {
288                Some(parent) => current_dir = parent,
289                None => break, // Reached filesystem root
290            }
291        }
292
293        ConfigSearchResult::NotFound { searched_paths }
294    }
295
296    /// Determine if search should stop at the current directory
297    fn should_stop_search(&self, dir: &Path) -> bool {
298        // Stop at workspace roots (for LSP mode)
299        for workspace_root in &self.workspace_roots {
300            if dir == workspace_root.as_path() {
301                return true;
302            }
303        }
304
305        // Stop at current working directory (for CLI mode)
306        if let Some(cwd) = &self.current_working_dir {
307            if dir == cwd.as_path() {
308                return true;
309            }
310        }
311
312        // Stop at git repository boundaries
313        if dir.join(".git").exists() {
314            return true;
315        }
316
317        false
318    }
319}
320
321/// Parse a TOML configuration string into a QuickmarkConfig
322pub 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
328/// Load configuration from QUICKMARK_CONFIG environment variable, path, or default
329pub fn config_from_env_path_or_default(path: &Path) -> Result<QuickmarkConfig> {
330    // First check if QUICKMARK_CONFIG environment variable is set
331    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    // Fallback to existing behavior - check for quickmark.toml in path
352    config_in_path_or_default(path)
353}
354
355/// Load configuration from a path, or return default if not found
356pub 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
369/// Convenience function that uses ConfigDiscovery to find config or return default
370pub 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
388/// Convenience function for LSP usage with workspace roots
389pub 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        // Create a local config in a temp directory
574        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        // Load config - should fall back to checking the provided path
585        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        // Should use default configuration
603        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        // Verify severities
748        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        // Verify heading-style settings
762        assert_eq!(
763            HeadingStyle::ATX,
764            parsed.linters.settings.heading_style.style
765        );
766
767        // Verify ul-style settings
768        use crate::rules::md004::UlStyle;
769        assert_eq!(UlStyle::Asterisk, parsed.linters.settings.ul_style.style);
770
771        // Verify ul-indent settings
772        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        // Verify trailing-spaces settings
777        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        // Verify line-length settings
788        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        // Verify single-h1 settings
801        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        // Verify ol-prefix settings
808        use crate::rules::md029::OlPrefixStyle;
809        assert_eq!(OlPrefixStyle::One, parsed.linters.settings.ol_prefix.style);
810
811        // Verify hard-tabs settings
812        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        // Verify multiple-blank-lines settings
820        assert_eq!(3, parsed.linters.settings.multiple_blank_lines.maximum);
821
822        // Verify headings-blanks settings
823        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        // Verify first-line-heading settings
833        assert!(parsed.linters.settings.first_line_heading.allow_preamble);
834
835        // Verify trailing-punctuation settings
836        assert_eq!(
837            ".,;:!?",
838            parsed.linters.settings.trailing_punctuation.punctuation
839        );
840
841        // Verify blockquote-spaces settings
842        assert!(!parsed.linters.settings.blockquote_spaces.list_items);
843
844        // Verify list-marker-space settings
845        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        // Verify fenced-code-blanks settings
851        assert!(!parsed.linters.settings.fenced_code_blanks.list_items);
852
853        // Verify inline-html settings
854        assert_eq!(
855            vec!["br", "img"],
856            parsed.linters.settings.inline_html.allowed_elements
857        );
858
859        // Verify hr-style settings
860        assert_eq!("asterisk", parsed.linters.settings.hr_style.style);
861
862        // Verify emphasis-as-heading settings
863        assert_eq!(
864            ".,;:!?",
865            parsed.linters.settings.emphasis_as_heading.punctuation
866        );
867
868        // Verify fenced-code-language settings
869        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        // Verify code-block-style settings
880        use crate::rules::md046::CodeBlockStyle;
881        assert_eq!(
882            CodeBlockStyle::Fenced,
883            parsed.linters.settings.code_block_style.style
884        );
885
886        // Verify code-fence-style settings
887        use crate::rules::md048::CodeFenceStyle;
888        assert_eq!(
889            CodeFenceStyle::Backtick,
890            parsed.linters.settings.code_fence_style.style
891        );
892
893        // Verify emphasis-style settings
894        use crate::rules::md049::EmphasisStyle;
895        assert_eq!(
896            EmphasisStyle::Asterisk,
897            parsed.linters.settings.emphasis_style.style
898        );
899
900        // Verify strong-style settings
901        use crate::rules::md050::StrongStyle;
902        assert_eq!(
903            StrongStyle::Underscore,
904            parsed.linters.settings.strong_style.style
905        );
906
907        // Verify multiple-headings settings
908        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        // Verify required-headings settings
918        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        // Verify proper-names settings
925        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        // Verify reference-links-images settings
933        assert_eq!(
934            vec!["x", "skip"],
935            parsed
936                .linters
937                .settings
938                .reference_links_images
939                .ignored_labels
940        );
941
942        // Verify link-image-reference-definitions settings
943        assert_eq!(
944            vec!["//", "skip"],
945            parsed
946                .linters
947                .settings
948                .link_image_reference_definitions
949                .ignored_definitions
950        );
951
952        // Verify link-image-style settings
953        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        // Verify table-pipe-style settings
961        use crate::rules::md055::TablePipeStyle;
962        assert_eq!(
963            TablePipeStyle::LeadingAndTrailing,
964            parsed.linters.settings.table_pipe_style.style
965        );
966
967        // Verify descriptive-link-text settings
968        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        // Verify all rules have Error severity (normalized default)
987        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        // Verify heading-style defaults
1005        assert_eq!(
1006            HeadingStyle::Consistent,
1007            parsed.linters.settings.heading_style.style
1008        );
1009
1010        // Verify ul-style defaults
1011        use crate::rules::md004::UlStyle;
1012        assert_eq!(UlStyle::Consistent, parsed.linters.settings.ul_style.style);
1013
1014        // Verify ul-indent defaults
1015        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        // Verify trailing-spaces defaults
1020        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        // Verify line-length defaults
1031        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        // Verify single-h1 defaults
1044        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        // Verify ol-prefix defaults
1051        use crate::rules::md029::OlPrefixStyle;
1052        assert_eq!(
1053            OlPrefixStyle::OneOrOrdered,
1054            parsed.linters.settings.ol_prefix.style
1055        );
1056
1057        // Verify multiple-blank-lines defaults
1058        assert_eq!(1, parsed.linters.settings.multiple_blank_lines.maximum);
1059
1060        // Verify hard-tabs defaults
1061        assert_eq!(1, parsed.linters.settings.hard_tabs.spaces_per_tab);
1062        assert!(parsed.linters.settings.hard_tabs.code_blocks);
1063
1064        // Verify first-line-heading defaults
1065        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        // Rules with explicit configuration should use that
1080        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        // Rules without explicit configuration should use default (Error)
1090        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        // Default should not appear in final severities map
1104        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        // Explicit rule should use Error
1118        assert_eq!(
1119            RuleSeverity::Error,
1120            *parsed.linters.severity.get("heading-style").unwrap()
1121        );
1122
1123        // All other rules should use default (Warning)
1124        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        // Default should not appear in final severities map
1138        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        // Explicit rules should use their configured severities
1153        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        // All other rules should be disabled (Off)
1163        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        // Default should not appear in final severities map
1177        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        // Valid explicit rules should be preserved
1194        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        // Invalid rules should be removed
1204        assert_eq!(None, parsed.linters.severity.get("invalid-rule"));
1205        assert_eq!(None, parsed.linters.severity.get("another-invalid"));
1206
1207        // Valid rules without explicit config should use default
1208        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        // Default should not appear in final severities map
1218        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        // Explicit rules should use their configured severities
1232        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        // Rules without explicit config should default to Error
1242        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                // Should have searched in temp_dir
1264                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        // Create a config file
1277        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        // Create a file in the same directory
1285        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        // Create nested directories: temp_dir/project/src/
1308        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        // Create config in project root
1313        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        // Create a file in src/
1321        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        // Create nested directories: temp_dir/repo/src/
1344        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        // Create .git directory (should stop search)
1349        std::fs::create_dir(repo_dir.join(".git")).unwrap();
1350
1351        // Create config outside repo (should NOT be found due to .git boundary)
1352        let outer_config = temp_dir.path().join("quickmark.toml");
1353        std::fs::write(&outer_config, "[linters.severity]\nheading-style = 'warn'").unwrap();
1354
1355        // Create file in src/
1356        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                // Should have searched in src/ and repo/ but not in temp_dir (stopped at .git)
1365                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        // Create nested directories: temp_dir/workspace/project/src/
1380        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        // Create config outside workspace (should not be found)
1386        let outer_config = temp_dir.path().join("quickmark.toml");
1387        std::fs::write(&outer_config, "[linters.severity]\nheading-style = 'warn'").unwrap();
1388
1389        // Create file in src/
1390        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                // Should have searched in src/, project/, and workspace/ but not temp_dir
1399                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        // Create invalid config file
1415        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        // Create a file in the same directory
1420        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")); // TOML parse error
1430            }
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        // Create a config file
1440        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        // Create a file in the same directory
1448        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        // Should return default config with normalized severities
1466        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        // Create workspace directory
1477        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        // Create config in workspace
1482        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        // Create file in project
1490        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        // Create structure: temp_dir/workspace/.git/project/
1508        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        // ConfigDiscovery with workspace root should stop at workspace, not .git
1514        let discovery = ConfigDiscovery::with_workspace_roots(vec![workspace_dir.clone()]);
1515
1516        // Should stop at workspace (highest priority)
1517        assert!(discovery.should_stop_search(&workspace_dir));
1518        // Should not stop at .git when workspace root is set
1519        assert!(!discovery.should_stop_search(&git_dir));
1520    }
1521}