Skip to main content

rumdl_lib/utils/
fix_utils.rs

1//! Utilities for applying fixes consistently between CLI and LSP
2//!
3//! This module provides shared logic for applying markdown fixes to ensure
4//! that both CLI batch fixes and LSP individual fixes produce identical results.
5
6use crate::inline_config::InlineConfig;
7use crate::rule::{Fix, LintWarning};
8use crate::utils::ensure_consistent_line_endings;
9
10/// Filter warnings by inline config, removing those on disabled lines.
11///
12/// Replicates the same filtering logic used in the check/reporting path
13/// (`src/lib.rs`) so that fix mode respects inline disable comments.
14pub fn filter_warnings_by_inline_config(
15    warnings: Vec<LintWarning>,
16    inline_config: &InlineConfig,
17    rule_name: &str,
18) -> Vec<LintWarning> {
19    let base_rule_name = if let Some(dash_pos) = rule_name.find('-') {
20        // Handle sub-rules like "MD029-style" -> "MD029"
21        // But only if the prefix looks like a rule ID (starts with "MD")
22        let prefix = &rule_name[..dash_pos];
23        if prefix.starts_with("MD") { prefix } else { rule_name }
24    } else {
25        rule_name
26    };
27
28    warnings
29        .into_iter()
30        .filter(|w| {
31            let end = if w.end_line >= w.line { w.end_line } else { w.line };
32            !(w.line..=end).any(|line| inline_config.is_rule_disabled(base_rule_name, line))
33        })
34        .collect()
35}
36
37/// Apply a list of warning fixes to content, simulating how the LSP client would apply them
38/// This is used for testing consistency between CLI and LSP fix methods
39pub fn apply_warning_fixes(content: &str, warnings: &[LintWarning]) -> Result<String, String> {
40    let mut fixes: Vec<(usize, &Fix)> = warnings
41        .iter()
42        .enumerate()
43        .filter_map(|(i, w)| w.fix.as_ref().map(|fix| (i, fix)))
44        .collect();
45
46    // Deduplicate fixes that operate on the same range with the same replacement
47    // This prevents double-application when multiple warnings target the same issue
48    fixes.sort_by(|(_, fix_a), (_, fix_b)| {
49        let range_cmp = fix_a.range.start.cmp(&fix_b.range.start);
50        if range_cmp != std::cmp::Ordering::Equal {
51            return range_cmp;
52        }
53        fix_a.range.end.cmp(&fix_b.range.end)
54    });
55
56    let mut deduplicated = Vec::new();
57    let mut i = 0;
58    while i < fixes.len() {
59        let (idx, current_fix) = fixes[i];
60        deduplicated.push((idx, current_fix));
61
62        // Skip any subsequent fixes that have the same range and replacement
63        while i + 1 < fixes.len() {
64            let (_, next_fix) = fixes[i + 1];
65            if current_fix.range == next_fix.range && current_fix.replacement == next_fix.replacement {
66                i += 1; // Skip the duplicate
67            } else {
68                break;
69            }
70        }
71        i += 1;
72    }
73
74    let mut fixes = deduplicated;
75
76    // Sort fixes by range in reverse order (end to start) to avoid offset issues
77    // Use original index as secondary sort key to ensure stable sorting
78    fixes.sort_by(|(idx_a, fix_a), (idx_b, fix_b)| {
79        // Primary: sort by range start in reverse order (largest first)
80        let range_cmp = fix_b.range.start.cmp(&fix_a.range.start);
81        if range_cmp != std::cmp::Ordering::Equal {
82            return range_cmp;
83        }
84
85        // Secondary: sort by range end in reverse order
86        let end_cmp = fix_b.range.end.cmp(&fix_a.range.end);
87        if end_cmp != std::cmp::Ordering::Equal {
88            return end_cmp;
89        }
90
91        // Tertiary: maintain original order for identical ranges (stable sort)
92        idx_a.cmp(idx_b)
93    });
94
95    let mut result = content.to_string();
96
97    for (_, fix) in fixes {
98        // Validate range bounds
99        if fix.range.end > result.len() {
100            return Err(format!(
101                "Fix range end {} exceeds content length {}",
102                fix.range.end,
103                result.len()
104            ));
105        }
106
107        if fix.range.start > fix.range.end {
108            return Err(format!(
109                "Invalid fix range: start {} > end {}",
110                fix.range.start, fix.range.end
111            ));
112        }
113
114        // Apply the fix by replacing the range with the replacement text
115        result.replace_range(fix.range.clone(), &fix.replacement);
116    }
117
118    // Ensure line endings are consistent with the original document
119    Ok(ensure_consistent_line_endings(content, &result))
120}
121
122/// Convert a single warning fix to a text edit-style representation
123/// This helps validate that individual warning fixes are correctly structured
124pub fn warning_fix_to_edit(content: &str, warning: &LintWarning) -> Result<(usize, usize, String), String> {
125    if let Some(fix) = &warning.fix {
126        // Validate the fix range against content
127        if fix.range.end > content.len() {
128            return Err(format!(
129                "Fix range end {} exceeds content length {}",
130                fix.range.end,
131                content.len()
132            ));
133        }
134
135        Ok((fix.range.start, fix.range.end, fix.replacement.clone()))
136    } else {
137        Err("Warning has no fix".to_string())
138    }
139}
140
141/// Helper function to validate that a fix range makes sense in the context
142pub fn validate_fix_range(content: &str, fix: &Fix) -> Result<(), String> {
143    if fix.range.start > content.len() {
144        return Err(format!(
145            "Fix range start {} exceeds content length {}",
146            fix.range.start,
147            content.len()
148        ));
149    }
150
151    if fix.range.end > content.len() {
152        return Err(format!(
153            "Fix range end {} exceeds content length {}",
154            fix.range.end,
155            content.len()
156        ));
157    }
158
159    if fix.range.start > fix.range.end {
160        return Err(format!(
161            "Invalid fix range: start {} > end {}",
162            fix.range.start, fix.range.end
163        ));
164    }
165
166    Ok(())
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::rule::{Fix, LintWarning, Severity};
173
174    #[test]
175    fn test_apply_single_fix() {
176        let content = "1.  Multiple spaces";
177        let warning = LintWarning {
178            message: "Too many spaces".to_string(),
179            line: 1,
180            column: 3,
181            end_line: 1,
182            end_column: 5,
183            severity: Severity::Warning,
184            fix: Some(Fix {
185                range: 2..4,                  // "  " (two spaces)
186                replacement: " ".to_string(), // single space
187            }),
188            rule_name: Some("MD030".to_string()),
189        };
190
191        let result = apply_warning_fixes(content, &[warning]).unwrap();
192        assert_eq!(result, "1. Multiple spaces");
193    }
194
195    #[test]
196    fn test_apply_multiple_fixes() {
197        let content = "1.  First\n*   Second";
198        let warnings = vec![
199            LintWarning {
200                message: "Too many spaces".to_string(),
201                line: 1,
202                column: 3,
203                end_line: 1,
204                end_column: 5,
205                severity: Severity::Warning,
206                fix: Some(Fix {
207                    range: 2..4, // First line "  "
208                    replacement: " ".to_string(),
209                }),
210                rule_name: Some("MD030".to_string()),
211            },
212            LintWarning {
213                message: "Too many spaces".to_string(),
214                line: 2,
215                column: 2,
216                end_line: 2,
217                end_column: 5,
218                severity: Severity::Warning,
219                fix: Some(Fix {
220                    range: 11..14, // Second line "   " (after newline + "*")
221                    replacement: " ".to_string(),
222                }),
223                rule_name: Some("MD030".to_string()),
224            },
225        ];
226
227        let result = apply_warning_fixes(content, &warnings).unwrap();
228        assert_eq!(result, "1. First\n* Second");
229    }
230
231    #[test]
232    fn test_apply_non_overlapping_fixes() {
233        // "Test  multiple    spaces"
234        //  0123456789012345678901234
235        //      ^^       ^^^^
236        //      4-6      14-18
237        let content = "Test  multiple    spaces";
238        let warnings = vec![
239            LintWarning {
240                message: "Too many spaces".to_string(),
241                line: 1,
242                column: 5,
243                end_line: 1,
244                end_column: 7,
245                severity: Severity::Warning,
246                fix: Some(Fix {
247                    range: 4..6, // "  " after "Test"
248                    replacement: " ".to_string(),
249                }),
250                rule_name: Some("MD009".to_string()),
251            },
252            LintWarning {
253                message: "Too many spaces".to_string(),
254                line: 1,
255                column: 15,
256                end_line: 1,
257                end_column: 19,
258                severity: Severity::Warning,
259                fix: Some(Fix {
260                    range: 14..18, // "    " after "multiple"
261                    replacement: " ".to_string(),
262                }),
263                rule_name: Some("MD009".to_string()),
264            },
265        ];
266
267        let result = apply_warning_fixes(content, &warnings).unwrap();
268        assert_eq!(result, "Test multiple spaces");
269    }
270
271    #[test]
272    fn test_apply_duplicate_fixes() {
273        let content = "Test  content";
274        let warnings = vec![
275            LintWarning {
276                message: "Fix 1".to_string(),
277                line: 1,
278                column: 5,
279                end_line: 1,
280                end_column: 7,
281                severity: Severity::Warning,
282                fix: Some(Fix {
283                    range: 4..6,
284                    replacement: " ".to_string(),
285                }),
286                rule_name: Some("MD009".to_string()),
287            },
288            LintWarning {
289                message: "Fix 2 (duplicate)".to_string(),
290                line: 1,
291                column: 5,
292                end_line: 1,
293                end_column: 7,
294                severity: Severity::Warning,
295                fix: Some(Fix {
296                    range: 4..6,
297                    replacement: " ".to_string(),
298                }),
299                rule_name: Some("MD009".to_string()),
300            },
301        ];
302
303        // Duplicates should be deduplicated
304        let result = apply_warning_fixes(content, &warnings).unwrap();
305        assert_eq!(result, "Test content");
306    }
307
308    #[test]
309    fn test_apply_fixes_with_windows_line_endings() {
310        let content = "1.  First\r\n*   Second\r\n";
311        let warnings = vec![
312            LintWarning {
313                message: "Too many spaces".to_string(),
314                line: 1,
315                column: 3,
316                end_line: 1,
317                end_column: 5,
318                severity: Severity::Warning,
319                fix: Some(Fix {
320                    range: 2..4,
321                    replacement: " ".to_string(),
322                }),
323                rule_name: Some("MD030".to_string()),
324            },
325            LintWarning {
326                message: "Too many spaces".to_string(),
327                line: 2,
328                column: 2,
329                end_line: 2,
330                end_column: 5,
331                severity: Severity::Warning,
332                fix: Some(Fix {
333                    range: 12..15, // Account for \r\n
334                    replacement: " ".to_string(),
335                }),
336                rule_name: Some("MD030".to_string()),
337            },
338        ];
339
340        let result = apply_warning_fixes(content, &warnings).unwrap();
341        // The implementation normalizes line endings, which may double \r
342        // Just test that the fixes were applied correctly
343        assert!(result.contains("1. First"));
344        assert!(result.contains("* Second"));
345    }
346
347    #[test]
348    fn test_apply_fix_with_invalid_range() {
349        let content = "Short";
350        let warning = LintWarning {
351            message: "Invalid fix".to_string(),
352            line: 1,
353            column: 1,
354            end_line: 1,
355            end_column: 10,
356            severity: Severity::Warning,
357            fix: Some(Fix {
358                range: 0..100, // Out of bounds
359                replacement: "Replacement".to_string(),
360            }),
361            rule_name: Some("TEST".to_string()),
362        };
363
364        let result = apply_warning_fixes(content, &[warning]);
365        assert!(result.is_err());
366        assert!(result.unwrap_err().contains("exceeds content length"));
367    }
368
369    #[test]
370    fn test_apply_fix_with_reversed_range() {
371        let content = "Hello world";
372        let warning = LintWarning {
373            message: "Invalid fix".to_string(),
374            line: 1,
375            column: 5,
376            end_line: 1,
377            end_column: 3,
378            severity: Severity::Warning,
379            fix: Some(Fix {
380                #[allow(clippy::reversed_empty_ranges)]
381                range: 10..5, // start > end - intentionally invalid for testing
382                replacement: "Test".to_string(),
383            }),
384            rule_name: Some("TEST".to_string()),
385        };
386
387        let result = apply_warning_fixes(content, &[warning]);
388        assert!(result.is_err());
389        assert!(result.unwrap_err().contains("Invalid fix range"));
390    }
391
392    #[test]
393    fn test_apply_no_fixes() {
394        let content = "No changes needed";
395        let warnings = vec![LintWarning {
396            message: "Warning without fix".to_string(),
397            line: 1,
398            column: 1,
399            end_line: 1,
400            end_column: 5,
401            severity: Severity::Warning,
402            fix: None,
403            rule_name: Some("TEST".to_string()),
404        }];
405
406        let result = apply_warning_fixes(content, &warnings).unwrap();
407        assert_eq!(result, content);
408    }
409
410    #[test]
411    fn test_warning_fix_to_edit() {
412        let content = "Hello world";
413        let warning = LintWarning {
414            message: "Test".to_string(),
415            line: 1,
416            column: 1,
417            end_line: 1,
418            end_column: 5,
419            severity: Severity::Warning,
420            fix: Some(Fix {
421                range: 0..5,
422                replacement: "Hi".to_string(),
423            }),
424            rule_name: Some("TEST".to_string()),
425        };
426
427        let edit = warning_fix_to_edit(content, &warning).unwrap();
428        assert_eq!(edit, (0, 5, "Hi".to_string()));
429    }
430
431    #[test]
432    fn test_warning_fix_to_edit_no_fix() {
433        let content = "Hello world";
434        let warning = LintWarning {
435            message: "Test".to_string(),
436            line: 1,
437            column: 1,
438            end_line: 1,
439            end_column: 5,
440            severity: Severity::Warning,
441            fix: None,
442            rule_name: Some("TEST".to_string()),
443        };
444
445        let result = warning_fix_to_edit(content, &warning);
446        assert!(result.is_err());
447        assert_eq!(result.unwrap_err(), "Warning has no fix");
448    }
449
450    #[test]
451    fn test_warning_fix_to_edit_invalid_range() {
452        let content = "Short";
453        let warning = LintWarning {
454            message: "Test".to_string(),
455            line: 1,
456            column: 1,
457            end_line: 1,
458            end_column: 10,
459            severity: Severity::Warning,
460            fix: Some(Fix {
461                range: 0..100,
462                replacement: "Long replacement".to_string(),
463            }),
464            rule_name: Some("TEST".to_string()),
465        };
466
467        let result = warning_fix_to_edit(content, &warning);
468        assert!(result.is_err());
469        assert!(result.unwrap_err().contains("exceeds content length"));
470    }
471
472    #[test]
473    fn test_validate_fix_range() {
474        let content = "Hello world";
475
476        // Valid range
477        let valid_fix = Fix {
478            range: 0..5,
479            replacement: "Hi".to_string(),
480        };
481        assert!(validate_fix_range(content, &valid_fix).is_ok());
482
483        // Invalid range (end > content length)
484        let invalid_fix = Fix {
485            range: 0..20,
486            replacement: "Hi".to_string(),
487        };
488        assert!(validate_fix_range(content, &invalid_fix).is_err());
489
490        // Invalid range (start > end) - create reversed range
491        let start = 5;
492        let end = 3;
493        let invalid_fix2 = Fix {
494            range: start..end,
495            replacement: "Hi".to_string(),
496        };
497        assert!(validate_fix_range(content, &invalid_fix2).is_err());
498    }
499
500    #[test]
501    fn test_validate_fix_range_edge_cases() {
502        let content = "Test";
503
504        // Empty range at start
505        let fix1 = Fix {
506            range: 0..0,
507            replacement: "Insert".to_string(),
508        };
509        assert!(validate_fix_range(content, &fix1).is_ok());
510
511        // Empty range at end
512        let fix2 = Fix {
513            range: 4..4,
514            replacement: " append".to_string(),
515        };
516        assert!(validate_fix_range(content, &fix2).is_ok());
517
518        // Full content replacement
519        let fix3 = Fix {
520            range: 0..4,
521            replacement: "Replace".to_string(),
522        };
523        assert!(validate_fix_range(content, &fix3).is_ok());
524
525        // Start exceeds content
526        let fix4 = Fix {
527            range: 10..11,
528            replacement: "Invalid".to_string(),
529        };
530        let result = validate_fix_range(content, &fix4);
531        assert!(result.is_err());
532        assert!(result.unwrap_err().contains("start 10 exceeds"));
533    }
534
535    #[test]
536    fn test_fix_ordering_stability() {
537        // Test that fixes with identical ranges maintain stable ordering
538        let content = "Test content here";
539        let warnings = vec![
540            LintWarning {
541                message: "First warning".to_string(),
542                line: 1,
543                column: 6,
544                end_line: 1,
545                end_column: 13,
546                severity: Severity::Warning,
547                fix: Some(Fix {
548                    range: 5..12, // "content"
549                    replacement: "stuff".to_string(),
550                }),
551                rule_name: Some("MD001".to_string()),
552            },
553            LintWarning {
554                message: "Second warning".to_string(),
555                line: 1,
556                column: 6,
557                end_line: 1,
558                end_column: 13,
559                severity: Severity::Warning,
560                fix: Some(Fix {
561                    range: 5..12, // Same range
562                    replacement: "stuff".to_string(),
563                }),
564                rule_name: Some("MD002".to_string()),
565            },
566        ];
567
568        // Both fixes are identical, so deduplication should leave only one
569        let result = apply_warning_fixes(content, &warnings).unwrap();
570        assert_eq!(result, "Test stuff here");
571    }
572
573    #[test]
574    fn test_line_ending_preservation() {
575        // Test Unix line endings
576        let content_unix = "Line 1\nLine 2\n";
577        let warning = LintWarning {
578            message: "Add text".to_string(),
579            line: 1,
580            column: 7,
581            end_line: 1,
582            end_column: 7,
583            severity: Severity::Warning,
584            fix: Some(Fix {
585                range: 6..6,
586                replacement: " added".to_string(),
587            }),
588            rule_name: Some("TEST".to_string()),
589        };
590
591        let result = apply_warning_fixes(content_unix, &[warning]).unwrap();
592        assert_eq!(result, "Line 1 added\nLine 2\n");
593
594        // Test that Windows line endings work (even if normalization occurs)
595        let content_windows = "Line 1\r\nLine 2\r\n";
596        let warning_windows = LintWarning {
597            message: "Add text".to_string(),
598            line: 1,
599            column: 7,
600            end_line: 1,
601            end_column: 7,
602            severity: Severity::Warning,
603            fix: Some(Fix {
604                range: 6..6,
605                replacement: " added".to_string(),
606            }),
607            rule_name: Some("TEST".to_string()),
608        };
609
610        let result_windows = apply_warning_fixes(content_windows, &[warning_windows]).unwrap();
611        // Just verify the fix was applied correctly
612        assert!(result_windows.starts_with("Line 1 added"));
613        assert!(result_windows.contains("Line 2"));
614    }
615
616    fn make_warning(line: usize, end_line: usize, rule_name: &str) -> LintWarning {
617        LintWarning {
618            message: "test".to_string(),
619            line,
620            column: 1,
621            end_line,
622            end_column: 1,
623            severity: Severity::Warning,
624            fix: Some(Fix {
625                range: 0..1,
626                replacement: "x".to_string(),
627            }),
628            rule_name: Some(rule_name.to_string()),
629        }
630    }
631
632    #[test]
633    fn test_filter_warnings_disable_enable_block() {
634        let content =
635            "# Heading\n\n<!-- rumdl-disable MD013 -->\nlong line\n<!-- rumdl-enable MD013 -->\nanother long line\n";
636        let inline_config = InlineConfig::from_content(content);
637
638        let warnings = vec![
639            make_warning(4, 4, "MD013"), // inside disabled block
640            make_warning(6, 6, "MD013"), // outside disabled block
641        ];
642
643        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
644        assert_eq!(filtered.len(), 1);
645        assert_eq!(filtered[0].line, 6);
646    }
647
648    #[test]
649    fn test_filter_warnings_disable_line() {
650        let content = "line one <!-- rumdl-disable-line MD009 -->\nline two\n";
651        let inline_config = InlineConfig::from_content(content);
652
653        let warnings = vec![
654            make_warning(1, 1, "MD009"), // disabled via disable-line
655            make_warning(2, 2, "MD009"), // not disabled
656        ];
657
658        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD009");
659        assert_eq!(filtered.len(), 1);
660        assert_eq!(filtered[0].line, 2);
661    }
662
663    #[test]
664    fn test_filter_warnings_disable_next_line() {
665        let content = "<!-- rumdl-disable-next-line MD034 -->\nhttp://example.com\nhttp://other.com\n";
666        let inline_config = InlineConfig::from_content(content);
667
668        let warnings = vec![
669            make_warning(2, 2, "MD034"), // disabled via disable-next-line
670            make_warning(3, 3, "MD034"), // not disabled
671        ];
672
673        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD034");
674        assert_eq!(filtered.len(), 1);
675        assert_eq!(filtered[0].line, 3);
676    }
677
678    #[test]
679    fn test_filter_warnings_sub_rule_name() {
680        let content = "<!-- rumdl-disable MD029 -->\nline\n<!-- rumdl-enable MD029 -->\nline\n";
681        let inline_config = InlineConfig::from_content(content);
682
683        // Sub-rule name like "MD029-style" should be stripped to "MD029"
684        let warnings = vec![make_warning(2, 2, "MD029"), make_warning(4, 4, "MD029")];
685
686        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD029-style");
687        assert_eq!(filtered.len(), 1);
688        assert_eq!(filtered[0].line, 4);
689    }
690
691    #[test]
692    fn test_filter_warnings_multi_line_warning() {
693        // A warning spanning lines 3-5 where line 4 is disabled
694        let content = "line 1\nline 2\nline 3\n<!-- rumdl-disable-line MD013 -->\nline 5\nline 6\n";
695        let inline_config = InlineConfig::from_content(content);
696
697        let warnings = vec![
698            make_warning(3, 5, "MD013"), // spans lines 3-5, line 4 is disabled
699            make_warning(6, 6, "MD013"), // not disabled
700        ];
701
702        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
703        // The multi-line warning should be filtered because one of its lines is disabled
704        assert_eq!(filtered.len(), 1);
705        assert_eq!(filtered[0].line, 6);
706    }
707
708    #[test]
709    fn test_filter_warnings_empty_input() {
710        let inline_config = InlineConfig::from_content("");
711        let filtered = filter_warnings_by_inline_config(vec![], &inline_config, "MD013");
712        assert!(filtered.is_empty());
713    }
714
715    #[test]
716    fn test_filter_warnings_none_disabled() {
717        let content = "line 1\nline 2\n";
718        let inline_config = InlineConfig::from_content(content);
719
720        let warnings = vec![make_warning(1, 1, "MD013"), make_warning(2, 2, "MD013")];
721
722        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
723        assert_eq!(filtered.len(), 2);
724    }
725
726    #[test]
727    fn test_filter_warnings_all_disabled() {
728        let content = "<!-- rumdl-disable MD013 -->\nline 1\nline 2\n";
729        let inline_config = InlineConfig::from_content(content);
730
731        let warnings = vec![make_warning(2, 2, "MD013"), make_warning(3, 3, "MD013")];
732
733        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
734        assert!(filtered.is_empty());
735    }
736
737    #[test]
738    fn test_filter_warnings_end_line_zero_fallback() {
739        // When end_line < line (e.g., end_line=0), should fall back to checking only warning.line
740        let content = "<!-- rumdl-disable-line MD013 -->\nline 2\n";
741        let inline_config = InlineConfig::from_content(content);
742
743        let warnings = vec![make_warning(1, 0, "MD013")]; // end_line=0 < line=1
744
745        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
746        assert!(filtered.is_empty());
747    }
748
749    #[test]
750    fn test_filter_non_md_rule_name_preserves_dash() {
751        // Verify that a non-MD rule name with a dash is NOT split by the helper.
752        // The helper should pass "custom-rule" as-is to InlineConfig, not "custom".
753        let content = "line 1\nline 2\n";
754        let inline_config = InlineConfig::from_content(content);
755
756        let warnings = vec![make_warning(1, 1, "custom-rule")];
757
758        // With nothing disabled, the warning should pass through
759        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "custom-rule");
760        assert_eq!(filtered.len(), 1, "Non-MD rule name with dash should not be split");
761    }
762
763    #[test]
764    fn test_filter_md_sub_rule_name_is_split() {
765        // Verify that "MD029-style" is split to "MD029" for inline config lookup
766        let content = "<!-- rumdl-disable MD029 -->\nline\n<!-- rumdl-enable MD029 -->\nline\n";
767        let inline_config = InlineConfig::from_content(content);
768
769        let warnings = vec![
770            make_warning(2, 2, "MD029"), // disabled
771            make_warning(4, 4, "MD029"), // not disabled
772        ];
773
774        // Passing "MD029-style" as rule_name should still match "MD029" in inline config
775        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD029-style");
776        assert_eq!(filtered.len(), 1);
777        assert_eq!(filtered[0].line, 4);
778    }
779
780    #[test]
781    fn test_filter_warnings_capture_restore() {
782        let content = "<!-- rumdl-disable MD013 -->\nline 1\n<!-- rumdl-capture -->\n<!-- rumdl-enable MD013 -->\nline 4\n<!-- rumdl-restore -->\nline 6\n";
783        let inline_config = InlineConfig::from_content(content);
784
785        let warnings = vec![
786            make_warning(2, 2, "MD013"), // disabled by initial disable
787            make_warning(5, 5, "MD013"), // re-enabled between capture/restore
788            make_warning(7, 7, "MD013"), // after restore, back to disabled state
789        ];
790
791        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
792        assert_eq!(filtered.len(), 1);
793        assert_eq!(filtered[0].line, 5);
794    }
795}