rumdl_lib/
rule.rs

1//!
2//! This module defines the Rule trait and related types for implementing linting rules in rumdl.
3//! Includes rule categories, dynamic dispatch helpers, and inline comment handling for rule enable/disable.
4
5use dyn_clone::DynClone;
6use serde::{Deserialize, Serialize};
7use std::ops::Range;
8use thiserror::Error;
9
10// Import document structure
11use crate::lint_context::LintContext;
12
13// Macro to implement box_clone for Rule implementors
14#[macro_export]
15macro_rules! impl_rule_clone {
16    ($ty:ty) => {
17        impl $ty {
18            fn box_clone(&self) -> Box<dyn Rule> {
19                Box::new(self.clone())
20            }
21        }
22    };
23}
24
25#[derive(Debug, Error)]
26pub enum LintError {
27    #[error("Invalid input: {0}")]
28    InvalidInput(String),
29    #[error("Fix failed: {0}")]
30    FixFailed(String),
31    #[error("IO error: {0}")]
32    IoError(#[from] std::io::Error),
33    #[error("Parsing error: {0}")]
34    ParsingError(String),
35}
36
37pub type LintResult = Result<Vec<LintWarning>, LintError>;
38
39#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
40pub struct LintWarning {
41    pub message: String,
42    pub line: usize,       // 1-indexed start line
43    pub column: usize,     // 1-indexed start column
44    pub end_line: usize,   // 1-indexed end line
45    pub end_column: usize, // 1-indexed end column
46    pub severity: Severity,
47    pub fix: Option<Fix>,
48    pub rule_name: Option<String>,
49}
50
51#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
52pub struct Fix {
53    pub range: Range<usize>,
54    pub replacement: String,
55}
56
57#[derive(Debug, PartialEq, Clone, Copy, Serialize)]
58pub enum Severity {
59    Error,
60    Warning,
61}
62
63impl<'de> serde::Deserialize<'de> for Severity {
64    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
65    where
66        D: serde::Deserializer<'de>,
67    {
68        let s = String::deserialize(deserializer)?;
69        match s.to_lowercase().as_str() {
70            "error" => Ok(Severity::Error),
71            "warning" => Ok(Severity::Warning),
72            _ => Err(serde::de::Error::custom(format!(
73                "Invalid severity: '{s}'. Valid values: error, warning"
74            ))),
75        }
76    }
77}
78
79/// Type of rule for selective processing
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum RuleCategory {
82    Heading,
83    List,
84    CodeBlock,
85    Link,
86    Image,
87    Html,
88    Emphasis,
89    Whitespace,
90    Blockquote,
91    Table,
92    FrontMatter,
93    Other,
94}
95
96/// Capability of a rule to fix issues
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum FixCapability {
99    /// Rule can automatically fix all violations it detects
100    FullyFixable,
101    /// Rule can fix some violations based on context
102    ConditionallyFixable,
103    /// Rule cannot fix violations (by design)
104    Unfixable,
105}
106
107/// Declares what cross-file data a rule needs
108///
109/// Most rules only need single-file context and should use `None` (the default).
110/// Rules that need to validate references across files (like MD051) should use `Workspace`.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
112pub enum CrossFileScope {
113    /// Single-file only - no cross-file analysis needed (default for 99% of rules)
114    #[default]
115    None,
116    /// Needs workspace-wide index for cross-file validation
117    Workspace,
118}
119
120/// Remove marker /// TRAIT_MARKER_V1
121pub trait Rule: DynClone + Send + Sync {
122    fn name(&self) -> &'static str;
123    fn description(&self) -> &'static str;
124    fn check(&self, ctx: &LintContext) -> LintResult;
125    fn fix(&self, ctx: &LintContext) -> Result<String, LintError>;
126
127    /// Check if this rule should quickly skip processing based on content
128    fn should_skip(&self, _ctx: &LintContext) -> bool {
129        false
130    }
131
132    /// Get the category of this rule for selective processing
133    fn category(&self) -> RuleCategory {
134        RuleCategory::Other // Default implementation returns Other
135    }
136
137    fn as_any(&self) -> &dyn std::any::Any;
138
139    // DocumentStructure has been merged into LintContext - this method is no longer used
140    // fn as_maybe_document_structure(&self) -> Option<&dyn MaybeDocumentStructure> {
141    //     None
142    // }
143
144    /// Returns the rule name and default config table if the rule has config.
145    /// If a rule implements this, it MUST be defined on the `impl Rule for ...` block,
146    /// not just the inherent impl.
147    fn default_config_section(&self) -> Option<(String, toml::Value)> {
148        None
149    }
150
151    /// Returns config key aliases for this rule
152    /// This allows rules to accept alternative config key names for backwards compatibility
153    fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
154        None
155    }
156
157    /// Declares the fix capability of this rule
158    fn fix_capability(&self) -> FixCapability {
159        FixCapability::FullyFixable // Safe default for backward compatibility
160    }
161
162    /// Declares cross-file analysis requirements for this rule
163    ///
164    /// Returns `CrossFileScope::None` by default, meaning the rule only needs
165    /// single-file context. Rules that need workspace-wide data should override
166    /// this to return `CrossFileScope::Workspace`.
167    fn cross_file_scope(&self) -> CrossFileScope {
168        CrossFileScope::None
169    }
170
171    /// Contribute data to the workspace index during linting
172    ///
173    /// Called during the single-file linting phase for rules that return
174    /// `CrossFileScope::Workspace`. Rules should extract headings, links,
175    /// and other data needed for cross-file validation.
176    ///
177    /// This is called as a side effect of linting, so LintContext is already
178    /// created - no duplicate parsing required.
179    fn contribute_to_index(&self, _ctx: &LintContext, _file_index: &mut crate::workspace_index::FileIndex) {
180        // Default: no contribution
181    }
182
183    /// Perform cross-file validation after all files have been linted
184    ///
185    /// Called once per file after the entire workspace has been indexed.
186    /// Rules receive the file_index (from contribute_to_index) and the full
187    /// workspace_index for cross-file lookups.
188    ///
189    /// Note: This receives the FileIndex instead of LintContext to avoid re-parsing
190    /// each file. The FileIndex was already populated during contribute_to_index.
191    ///
192    /// Rules can use workspace_index methods for cross-file validation:
193    /// - `get_file(path)` - to look up headings in target files (for MD051)
194    /// - `files()` - to iterate all indexed files
195    ///
196    /// Returns additional warnings for cross-file issues. These are appended
197    /// to the single-file warnings.
198    fn cross_file_check(
199        &self,
200        _file_path: &std::path::Path,
201        _file_index: &crate::workspace_index::FileIndex,
202        _workspace_index: &crate::workspace_index::WorkspaceIndex,
203    ) -> LintResult {
204        Ok(Vec::new()) // Default: no cross-file warnings
205    }
206
207    /// Factory: create a rule from config (if present), or use defaults.
208    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
209    where
210        Self: Sized,
211    {
212        panic!(
213            "from_config not implemented for rule: {}",
214            std::any::type_name::<Self>()
215        );
216    }
217}
218
219// Implement the cloning logic for the Rule trait object
220dyn_clone::clone_trait_object!(Rule);
221
222/// Extension trait to add downcasting capabilities to Rule
223pub trait RuleExt {
224    fn downcast_ref<T: 'static>(&self) -> Option<&T>;
225}
226
227impl<R: Rule + 'static> RuleExt for Box<R> {
228    fn downcast_ref<T: 'static>(&self) -> Option<&T> {
229        if std::any::TypeId::of::<R>() == std::any::TypeId::of::<T>() {
230            unsafe { Some(&*(self.as_ref() as *const _ as *const T)) }
231        } else {
232            None
233        }
234    }
235}
236
237/// Check if a rule is disabled at a specific line via inline comments
238pub fn is_rule_disabled_at_line(content: &str, rule_name: &str, line_num: usize) -> bool {
239    let lines: Vec<&str> = content.lines().collect();
240    let mut is_disabled = false;
241
242    // Check for both markdownlint-disable and rumdl-disable comments
243    for (i, line) in lines.iter().enumerate() {
244        // Stop processing once we reach the target line
245        if i > line_num {
246            break;
247        }
248
249        // Skip comments that are inside code blocks
250        if crate::rules::code_block_utils::CodeBlockUtils::is_in_code_block(content, i) {
251            continue;
252        }
253
254        let line = line.trim();
255
256        // Check for disable comments (both global and rule-specific)
257        if let Some(rules) = parse_disable_comment(line)
258            && (rules.is_empty() || rules.contains(&rule_name))
259        {
260            is_disabled = true;
261            continue;
262        }
263
264        // Check for enable comments (both global and rule-specific)
265        if let Some(rules) = parse_enable_comment(line)
266            && (rules.is_empty() || rules.contains(&rule_name))
267        {
268            is_disabled = false;
269            continue;
270        }
271    }
272
273    is_disabled
274}
275
276/// Parse a disable comment and return the list of rules (empty vec means all rules)
277pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
278    // Check for rumdl-disable first (preferred syntax)
279    if let Some(start) = line.find("<!-- rumdl-disable") {
280        let after_prefix = &line[start + "<!-- rumdl-disable".len()..];
281
282        // Global disable: <!-- rumdl-disable -->
283        if after_prefix.trim_start().starts_with("-->") {
284            return Some(Vec::new()); // Empty vec means all rules
285        }
286
287        // Rule-specific disable: <!-- rumdl-disable MD001 MD002 -->
288        if let Some(end) = after_prefix.find("-->") {
289            let rules_str = after_prefix[..end].trim();
290            if !rules_str.is_empty() {
291                let rules: Vec<&str> = rules_str.split_whitespace().collect();
292                return Some(rules);
293            }
294        }
295    }
296
297    // Check for markdownlint-disable (compatibility)
298    if let Some(start) = line.find("<!-- markdownlint-disable") {
299        let after_prefix = &line[start + "<!-- markdownlint-disable".len()..];
300
301        // Global disable: <!-- markdownlint-disable -->
302        if after_prefix.trim_start().starts_with("-->") {
303            return Some(Vec::new()); // Empty vec means all rules
304        }
305
306        // Rule-specific disable: <!-- markdownlint-disable MD001 MD002 -->
307        if let Some(end) = after_prefix.find("-->") {
308            let rules_str = after_prefix[..end].trim();
309            if !rules_str.is_empty() {
310                let rules: Vec<&str> = rules_str.split_whitespace().collect();
311                return Some(rules);
312            }
313        }
314    }
315
316    None
317}
318
319/// Parse an enable comment and return the list of rules (empty vec means all rules)
320pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
321    // Check for rumdl-enable first (preferred syntax)
322    if let Some(start) = line.find("<!-- rumdl-enable") {
323        let after_prefix = &line[start + "<!-- rumdl-enable".len()..];
324
325        // Global enable: <!-- rumdl-enable -->
326        if after_prefix.trim_start().starts_with("-->") {
327            return Some(Vec::new()); // Empty vec means all rules
328        }
329
330        // Rule-specific enable: <!-- rumdl-enable MD001 MD002 -->
331        if let Some(end) = after_prefix.find("-->") {
332            let rules_str = after_prefix[..end].trim();
333            if !rules_str.is_empty() {
334                let rules: Vec<&str> = rules_str.split_whitespace().collect();
335                return Some(rules);
336            }
337        }
338    }
339
340    // Check for markdownlint-enable (compatibility)
341    if let Some(start) = line.find("<!-- markdownlint-enable") {
342        let after_prefix = &line[start + "<!-- markdownlint-enable".len()..];
343
344        // Global enable: <!-- markdownlint-enable -->
345        if after_prefix.trim_start().starts_with("-->") {
346            return Some(Vec::new()); // Empty vec means all rules
347        }
348
349        // Rule-specific enable: <!-- markdownlint-enable MD001 MD002 -->
350        if let Some(end) = after_prefix.find("-->") {
351            let rules_str = after_prefix[..end].trim();
352            if !rules_str.is_empty() {
353                let rules: Vec<&str> = rules_str.split_whitespace().collect();
354                return Some(rules);
355            }
356        }
357    }
358
359    None
360}
361
362/// Check if a rule is disabled via inline comments in the file content (for backward compatibility)
363pub fn is_rule_disabled_by_comment(content: &str, rule_name: &str) -> bool {
364    // Check if the rule is disabled at the end of the file
365    let lines: Vec<&str> = content.lines().collect();
366    is_rule_disabled_at_line(content, rule_name, lines.len())
367}
368
369// DocumentStructure has been merged into LintContext - these traits are no longer needed
370// The functionality is now directly available through LintContext methods
371/*
372// Helper trait for dynamic dispatch to check_with_structure
373pub trait MaybeDocumentStructure {
374    fn check_with_structure_opt(
375        &self,
376        ctx: &LintContext,
377        structure: &crate::utils::document_structure::DocumentStructure,
378    ) -> Option<LintResult>;
379}
380
381impl<T> MaybeDocumentStructure for T
382where
383    T: Rule + crate::utils::document_structure::DocumentStructureExtensions + 'static,
384{
385    fn check_with_structure_opt(
386        &self,
387        ctx: &LintContext,
388        structure: &crate::utils::document_structure::DocumentStructure,
389    ) -> Option<LintResult> {
390        Some(self.check_with_structure(ctx, structure))
391    }
392}
393
394impl MaybeDocumentStructure for dyn Rule {
395    fn check_with_structure_opt(
396        &self,
397        _ctx: &LintContext,
398        _structure: &crate::utils::document_structure::DocumentStructure,
399    ) -> Option<LintResult> {
400        None
401    }
402}
403*/
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_parse_disable_comment() {
411        // Test rumdl-disable global
412        assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
413
414        // Test rumdl-disable specific rules
415        assert_eq!(
416            parse_disable_comment("<!-- rumdl-disable MD001 MD002 -->"),
417            Some(vec!["MD001", "MD002"])
418        );
419
420        // Test markdownlint-disable global
421        assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
422
423        // Test markdownlint-disable specific rules
424        assert_eq!(
425            parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
426            Some(vec!["MD001", "MD002"])
427        );
428
429        // Test non-disable comment
430        assert_eq!(parse_disable_comment("<!-- some other comment -->"), None);
431
432        // Test with extra whitespace
433        assert_eq!(
434            parse_disable_comment("  <!-- rumdl-disable MD013 -->  "),
435            Some(vec!["MD013"])
436        );
437    }
438
439    #[test]
440    fn test_parse_enable_comment() {
441        // Test rumdl-enable global
442        assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
443
444        // Test rumdl-enable specific rules
445        assert_eq!(
446            parse_enable_comment("<!-- rumdl-enable MD001 MD002 -->"),
447            Some(vec!["MD001", "MD002"])
448        );
449
450        // Test markdownlint-enable global
451        assert_eq!(parse_enable_comment("<!-- markdownlint-enable -->"), Some(vec![]));
452
453        // Test markdownlint-enable specific rules
454        assert_eq!(
455            parse_enable_comment("<!-- markdownlint-enable MD001 MD002 -->"),
456            Some(vec!["MD001", "MD002"])
457        );
458
459        // Test non-enable comment
460        assert_eq!(parse_enable_comment("<!-- some other comment -->"), None);
461    }
462
463    #[test]
464    fn test_is_rule_disabled_at_line() {
465        let content = r#"# Test
466<!-- rumdl-disable MD013 -->
467This is a long line
468<!-- rumdl-enable MD013 -->
469This is another line
470<!-- markdownlint-disable MD042 -->
471Empty link: []()
472<!-- markdownlint-enable MD042 -->
473Final line"#;
474
475        // Test MD013 disabled at line 2 (0-indexed line 1)
476        assert!(is_rule_disabled_at_line(content, "MD013", 2));
477
478        // Test MD013 enabled at line 4 (0-indexed line 3)
479        assert!(!is_rule_disabled_at_line(content, "MD013", 4));
480
481        // Test MD042 disabled at line 6 (0-indexed line 5)
482        assert!(is_rule_disabled_at_line(content, "MD042", 6));
483
484        // Test MD042 enabled at line 8 (0-indexed line 7)
485        assert!(!is_rule_disabled_at_line(content, "MD042", 8));
486
487        // Test rule that's never disabled
488        assert!(!is_rule_disabled_at_line(content, "MD001", 5));
489    }
490
491    #[test]
492    fn test_parse_disable_comment_edge_cases() {
493        // Test with no space before closing
494        assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
495
496        // Test with multiple spaces - the implementation doesn't handle leading spaces in comment
497        assert_eq!(
498            parse_disable_comment("<!--   rumdl-disable   MD001   MD002   -->"),
499            None
500        );
501
502        // Test with tabs
503        assert_eq!(
504            parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
505            Some(vec!["MD001", "MD002"])
506        );
507
508        // Test comment not at start of line
509        assert_eq!(
510            parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
511            Some(vec!["MD001"])
512        );
513
514        // Test malformed comment (no closing)
515        assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
516
517        // Test malformed comment (no opening)
518        assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
519
520        // Test case sensitivity
521        assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
522        assert_eq!(parse_disable_comment("<!-- RuMdL-DiSaBlE -->"), None);
523
524        // Test with newlines - implementation finds the comment
525        assert_eq!(
526            parse_disable_comment("<!-- rumdl-disable\nMD001 -->"),
527            Some(vec!["MD001"])
528        );
529
530        // Test empty rule list
531        assert_eq!(parse_disable_comment("<!-- rumdl-disable   -->"), Some(vec![]));
532
533        // Test duplicate rules
534        assert_eq!(
535            parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
536            Some(vec!["MD001", "MD001", "MD002"])
537        );
538    }
539
540    #[test]
541    fn test_parse_enable_comment_edge_cases() {
542        // Test with no space before closing
543        assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
544
545        // Test with multiple spaces - the implementation doesn't handle leading spaces in comment
546        assert_eq!(parse_enable_comment("<!--   rumdl-enable   MD001   MD002   -->"), None);
547
548        // Test with tabs
549        assert_eq!(
550            parse_enable_comment("<!-- rumdl-enable\tMD001\tMD002 -->"),
551            Some(vec!["MD001", "MD002"])
552        );
553
554        // Test comment not at start of line
555        assert_eq!(
556            parse_enable_comment("Some text <!-- rumdl-enable MD001 --> more text"),
557            Some(vec!["MD001"])
558        );
559
560        // Test malformed comment (no closing)
561        assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
562
563        // Test malformed comment (no opening)
564        assert_eq!(parse_enable_comment("rumdl-enable MD001 -->"), None);
565
566        // Test case sensitivity
567        assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
568        assert_eq!(parse_enable_comment("<!-- RuMdL-EnAbLe -->"), None);
569
570        // Test with newlines - implementation finds the comment
571        assert_eq!(
572            parse_enable_comment("<!-- rumdl-enable\nMD001 -->"),
573            Some(vec!["MD001"])
574        );
575
576        // Test empty rule list
577        assert_eq!(parse_enable_comment("<!-- rumdl-enable   -->"), Some(vec![]));
578
579        // Test duplicate rules
580        assert_eq!(
581            parse_enable_comment("<!-- rumdl-enable MD001 MD001 MD002 -->"),
582            Some(vec!["MD001", "MD001", "MD002"])
583        );
584    }
585
586    #[test]
587    fn test_nested_disable_enable_comments() {
588        let content = r#"# Document
589<!-- rumdl-disable -->
590All rules disabled here
591<!-- rumdl-disable MD001 -->
592Still all disabled (redundant)
593<!-- rumdl-enable MD001 -->
594Only MD001 enabled, others still disabled
595<!-- rumdl-enable -->
596All rules enabled again"#;
597
598        // Line 2: All rules disabled
599        assert!(is_rule_disabled_at_line(content, "MD001", 2));
600        assert!(is_rule_disabled_at_line(content, "MD002", 2));
601
602        // Line 4: Still all disabled
603        assert!(is_rule_disabled_at_line(content, "MD001", 4));
604        assert!(is_rule_disabled_at_line(content, "MD002", 4));
605
606        // Line 6: Only MD001 enabled
607        assert!(!is_rule_disabled_at_line(content, "MD001", 6));
608        assert!(is_rule_disabled_at_line(content, "MD002", 6));
609
610        // Line 8: All enabled
611        assert!(!is_rule_disabled_at_line(content, "MD001", 8));
612        assert!(!is_rule_disabled_at_line(content, "MD002", 8));
613    }
614
615    #[test]
616    fn test_mixed_comment_styles() {
617        let content = r#"# Document
618<!-- markdownlint-disable MD001 -->
619MD001 disabled via markdownlint
620<!-- rumdl-enable MD001 -->
621MD001 enabled via rumdl
622<!-- rumdl-disable -->
623All disabled via rumdl
624<!-- markdownlint-enable -->
625All enabled via markdownlint"#;
626
627        // Line 2: MD001 disabled
628        assert!(is_rule_disabled_at_line(content, "MD001", 2));
629        assert!(!is_rule_disabled_at_line(content, "MD002", 2));
630
631        // Line 4: MD001 enabled
632        assert!(!is_rule_disabled_at_line(content, "MD001", 4));
633        assert!(!is_rule_disabled_at_line(content, "MD002", 4));
634
635        // Line 6: All disabled
636        assert!(is_rule_disabled_at_line(content, "MD001", 6));
637        assert!(is_rule_disabled_at_line(content, "MD002", 6));
638
639        // Line 8: All enabled
640        assert!(!is_rule_disabled_at_line(content, "MD001", 8));
641        assert!(!is_rule_disabled_at_line(content, "MD002", 8));
642    }
643
644    #[test]
645    fn test_comments_in_code_blocks() {
646        let content = r#"# Document
647```markdown
648<!-- rumdl-disable MD001 -->
649This is in a code block, should not affect rules
650```
651MD001 should still be enabled here"#;
652
653        // Comments inside code blocks should be ignored
654        assert!(!is_rule_disabled_at_line(content, "MD001", 5));
655
656        // Test with indented code blocks too
657        let indented_content = r#"# Document
658
659    <!-- rumdl-disable MD001 -->
660    This is in an indented code block
661
662MD001 should still be enabled here"#;
663
664        assert!(!is_rule_disabled_at_line(indented_content, "MD001", 5));
665    }
666
667    #[test]
668    fn test_comments_with_unicode() {
669        // Test with unicode in comments
670        assert_eq!(
671            parse_disable_comment("<!-- rumdl-disable MD001 --> 你好"),
672            Some(vec!["MD001"])
673        );
674
675        assert_eq!(
676            parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
677            Some(vec!["MD001"])
678        );
679    }
680
681    #[test]
682    fn test_rule_disabled_at_specific_lines() {
683        let content = r#"Line 0
684<!-- rumdl-disable MD001 MD002 -->
685Line 2
686Line 3
687<!-- rumdl-enable MD001 -->
688Line 5
689<!-- rumdl-disable -->
690Line 7
691<!-- rumdl-enable MD002 -->
692Line 9"#;
693
694        // Test each line's state
695        assert!(!is_rule_disabled_at_line(content, "MD001", 0));
696        assert!(!is_rule_disabled_at_line(content, "MD002", 0));
697
698        assert!(is_rule_disabled_at_line(content, "MD001", 2));
699        assert!(is_rule_disabled_at_line(content, "MD002", 2));
700
701        assert!(is_rule_disabled_at_line(content, "MD001", 3));
702        assert!(is_rule_disabled_at_line(content, "MD002", 3));
703
704        assert!(!is_rule_disabled_at_line(content, "MD001", 5));
705        assert!(is_rule_disabled_at_line(content, "MD002", 5));
706
707        assert!(is_rule_disabled_at_line(content, "MD001", 7));
708        assert!(is_rule_disabled_at_line(content, "MD002", 7));
709
710        assert!(is_rule_disabled_at_line(content, "MD001", 9));
711        assert!(!is_rule_disabled_at_line(content, "MD002", 9));
712    }
713
714    #[test]
715    fn test_is_rule_disabled_by_comment() {
716        let content = r#"# Document
717<!-- rumdl-disable MD001 -->
718Content here"#;
719
720        assert!(is_rule_disabled_by_comment(content, "MD001"));
721        assert!(!is_rule_disabled_by_comment(content, "MD002"));
722
723        let content2 = r#"# Document
724<!-- rumdl-disable -->
725Content here"#;
726
727        assert!(is_rule_disabled_by_comment(content2, "MD001"));
728        assert!(is_rule_disabled_by_comment(content2, "MD002"));
729    }
730
731    #[test]
732    fn test_comment_at_end_of_file() {
733        let content = "# Document\nContent\n<!-- rumdl-disable MD001 -->";
734
735        // Rule should be disabled for the entire file
736        assert!(is_rule_disabled_by_comment(content, "MD001"));
737        // Line indexing - the comment is at line 2 (0-indexed), so line 1 isn't affected
738        assert!(!is_rule_disabled_at_line(content, "MD001", 1));
739        // But it is disabled at line 2
740        assert!(is_rule_disabled_at_line(content, "MD001", 2));
741    }
742
743    #[test]
744    fn test_multiple_comments_same_line() {
745        // Only the first comment should be processed
746        assert_eq!(
747            parse_disable_comment("<!-- rumdl-disable MD001 --> <!-- rumdl-disable MD002 -->"),
748            Some(vec!["MD001"])
749        );
750
751        assert_eq!(
752            parse_enable_comment("<!-- rumdl-enable MD001 --> <!-- rumdl-enable MD002 -->"),
753            Some(vec!["MD001"])
754        );
755    }
756
757    #[test]
758    fn test_severity_serialization() {
759        let warning = LintWarning {
760            message: "Test warning".to_string(),
761            line: 1,
762            column: 1,
763            end_line: 1,
764            end_column: 10,
765            severity: Severity::Warning,
766            fix: None,
767            rule_name: Some("MD001".to_string()),
768        };
769
770        let serialized = serde_json::to_string(&warning).unwrap();
771        assert!(serialized.contains("\"severity\":\"Warning\""));
772
773        let error = LintWarning {
774            severity: Severity::Error,
775            ..warning
776        };
777
778        let serialized = serde_json::to_string(&error).unwrap();
779        assert!(serialized.contains("\"severity\":\"Error\""));
780    }
781
782    #[test]
783    fn test_fix_serialization() {
784        let fix = Fix {
785            range: 0..10,
786            replacement: "fixed text".to_string(),
787        };
788
789        let warning = LintWarning {
790            message: "Test warning".to_string(),
791            line: 1,
792            column: 1,
793            end_line: 1,
794            end_column: 10,
795            severity: Severity::Warning,
796            fix: Some(fix),
797            rule_name: Some("MD001".to_string()),
798        };
799
800        let serialized = serde_json::to_string(&warning).unwrap();
801        assert!(serialized.contains("\"fix\""));
802        assert!(serialized.contains("\"replacement\":\"fixed text\""));
803    }
804
805    #[test]
806    fn test_rule_category_equality() {
807        assert_eq!(RuleCategory::Heading, RuleCategory::Heading);
808        assert_ne!(RuleCategory::Heading, RuleCategory::List);
809
810        // Test all categories are distinct
811        let categories = [
812            RuleCategory::Heading,
813            RuleCategory::List,
814            RuleCategory::CodeBlock,
815            RuleCategory::Link,
816            RuleCategory::Image,
817            RuleCategory::Html,
818            RuleCategory::Emphasis,
819            RuleCategory::Whitespace,
820            RuleCategory::Blockquote,
821            RuleCategory::Table,
822            RuleCategory::FrontMatter,
823            RuleCategory::Other,
824        ];
825
826        for (i, cat1) in categories.iter().enumerate() {
827            for (j, cat2) in categories.iter().enumerate() {
828                if i == j {
829                    assert_eq!(cat1, cat2);
830                } else {
831                    assert_ne!(cat1, cat2);
832                }
833            }
834        }
835    }
836
837    #[test]
838    fn test_lint_error_conversions() {
839        use std::io;
840
841        // Test From<io::Error>
842        let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
843        let lint_error: LintError = io_error.into();
844        match lint_error {
845            LintError::IoError(_) => {}
846            _ => panic!("Expected IoError variant"),
847        }
848
849        // Test Display trait
850        let invalid_input = LintError::InvalidInput("bad input".to_string());
851        assert_eq!(invalid_input.to_string(), "Invalid input: bad input");
852
853        let fix_failed = LintError::FixFailed("couldn't fix".to_string());
854        assert_eq!(fix_failed.to_string(), "Fix failed: couldn't fix");
855
856        let parsing_error = LintError::ParsingError("parse error".to_string());
857        assert_eq!(parsing_error.to_string(), "Parsing error: parse error");
858    }
859
860    #[test]
861    fn test_empty_content_edge_cases() {
862        assert!(!is_rule_disabled_at_line("", "MD001", 0));
863        assert!(!is_rule_disabled_by_comment("", "MD001"));
864
865        // Single line with just comment
866        let single_comment = "<!-- rumdl-disable -->";
867        assert!(is_rule_disabled_at_line(single_comment, "MD001", 0));
868        assert!(is_rule_disabled_by_comment(single_comment, "MD001"));
869    }
870
871    #[test]
872    fn test_very_long_rule_list() {
873        let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
874        let comment = format!("<!-- rumdl-disable {many_rules} -->");
875
876        let parsed = parse_disable_comment(&comment);
877        assert!(parsed.is_some());
878        assert_eq!(parsed.unwrap().len(), 100);
879    }
880
881    #[test]
882    fn test_comment_with_special_characters() {
883        // Test with various special characters that might appear
884        assert_eq!(
885            parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
886            Some(vec!["MD001-test"])
887        );
888
889        assert_eq!(
890            parse_disable_comment("<!-- rumdl-disable MD_001 -->"),
891            Some(vec!["MD_001"])
892        );
893
894        assert_eq!(
895            parse_disable_comment("<!-- rumdl-disable MD.001 -->"),
896            Some(vec!["MD.001"])
897        );
898    }
899}