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