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