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