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