Skip to main content

rumdl_lib/
fix_coordinator.rs

1use crate::config::Config;
2use crate::lint_context::LintContext;
3use crate::rule::{FixCapability, LintWarning, Rule};
4use std::collections::hash_map::DefaultHasher;
5use std::collections::{HashMap, HashSet};
6use std::hash::{Hash, Hasher};
7
8/// Maximum number of fix iterations before stopping (same as Ruff)
9const MAX_ITERATIONS: usize = 100;
10
11/// Result of applying fixes iteratively
12///
13/// This struct provides named fields instead of a tuple to prevent
14/// confusion about the meaning of each value.
15#[derive(Debug, Clone)]
16pub struct FixResult {
17    /// Total number of rules that successfully applied fixes
18    pub rules_fixed: usize,
19    /// Number of fix iterations performed
20    pub iterations: usize,
21    /// Number of LintContext instances created during fixing
22    pub context_creations: usize,
23    /// Names of rules that applied fixes
24    pub fixed_rule_names: HashSet<String>,
25    /// Whether the fix process converged (content stabilized)
26    pub converged: bool,
27    /// Rules identified as participants in an oscillation cycle.
28    /// Populated only when `converged == false` and a cycle was detected.
29    /// Empty when the fix loop hit `max_iterations` without cycling.
30    pub conflicting_rules: Vec<String>,
31    /// Ordered rule sequence observed in the cycle.
32    /// If non-empty, this can be rendered as a loop by appending the first rule
33    /// at the end (e.g. `MD044 -> MD063 -> MD044`).
34    pub conflict_cycle: Vec<String>,
35}
36
37/// Calculate hash of content for convergence detection
38fn hash_content(content: &str) -> u64 {
39    let mut hasher = DefaultHasher::new();
40    content.hash(&mut hasher);
41    hasher.finish()
42}
43
44/// Coordinates rule fixing to minimize the number of passes needed
45pub struct FixCoordinator {
46    /// Rules that should run before others (rule -> rules that depend on it)
47    dependencies: HashMap<&'static str, Vec<&'static str>>,
48}
49
50impl Default for FixCoordinator {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56impl FixCoordinator {
57    pub fn new() -> Self {
58        let mut dependencies = HashMap::new();
59
60        // CRITICAL DEPENDENCIES:
61        // These dependencies prevent cascading issues that require multiple passes
62
63        // MD064 (multiple consecutive spaces) MUST run before:
64        // - MD010 (tabs->spaces) - MD010 replaces tabs with multiple spaces (e.g., 4),
65        //   which MD064 would incorrectly collapse back to 1 space if it ran after
66        dependencies.insert("MD064", vec!["MD010"]);
67
68        // MD010 (tabs->spaces) MUST run before:
69        // - MD007 (list indentation) - because tabs affect indent calculation
70        // - MD005 (list indent consistency) - same reason
71        dependencies.insert("MD010", vec!["MD007", "MD005"]);
72
73        // MD013 (line length) MUST run before:
74        // - MD009 (trailing spaces) - line wrapping might add trailing spaces that need cleanup
75        // - MD012 (multiple blanks) - reflowing can affect blank lines
76        // Note: MD013 now trims trailing whitespace during reflow to prevent mid-line spaces
77        dependencies.insert("MD013", vec!["MD009", "MD012"]);
78
79        // MD004 (list style) should run before:
80        // - MD007 (list indentation) - changing markers affects indentation
81        dependencies.insert("MD004", vec!["MD007"]);
82
83        // MD022/MD023 (heading spacing) should run before:
84        // - MD012 (multiple blanks) - heading fixes can affect blank lines
85        dependencies.insert("MD022", vec!["MD012"]);
86        dependencies.insert("MD023", vec!["MD012"]);
87
88        // MD070 (nested fence collision) MUST run before:
89        // - MD040 (code language) - MD070 changes block structure, making orphan fences into content
90        // - MD031 (blanks around fences) - same reason
91        dependencies.insert("MD070", vec!["MD040", "MD031"]);
92
93        // MD005/MD077 (list indent and continuation indent) MUST run before:
94        // - MD032 (blanks around lists) - MD005/MD077 fix nesting structure that MD032 relies on
95        //   to correctly identify list block boundaries; running MD032 first on under-indented
96        //   content causes spurious blank-line insertions inside list items
97        dependencies.insert("MD005", vec!["MD032"]);
98        dependencies.insert("MD077", vec!["MD032"]);
99
100        Self { dependencies }
101    }
102
103    /// Get the optimal order for running rules based on dependencies
104    pub fn get_optimal_order<'a>(&self, rules: &'a [Box<dyn Rule>]) -> Vec<&'a dyn Rule> {
105        // Build a map of rule names to rules for quick lookup
106        let rule_map: HashMap<&str, &dyn Rule> = rules.iter().map(|r| (r.name(), r.as_ref())).collect();
107
108        // Build reverse dependencies (rule -> rules it depends on)
109        let mut reverse_deps: HashMap<&str, HashSet<&str>> = HashMap::new();
110        for (prereq, dependents) in &self.dependencies {
111            for dependent in dependents {
112                reverse_deps.entry(dependent).or_default().insert(prereq);
113            }
114        }
115
116        // Perform topological sort
117        let mut sorted = Vec::new();
118        let mut visited: HashSet<&str> = HashSet::new();
119        let mut visiting: HashSet<&str> = HashSet::new();
120
121        fn visit<'a, 'b>(
122            rule_name: &'b str,
123            rule_map: &HashMap<&str, &'a dyn Rule>,
124            reverse_deps: &HashMap<&'b str, HashSet<&'b str>>,
125            visited: &mut HashSet<&'b str>,
126            visiting: &mut HashSet<&'b str>,
127            sorted: &mut Vec<&'a dyn Rule>,
128        ) where
129            'a: 'b,
130        {
131            if visited.contains(rule_name) {
132                return;
133            }
134
135            if visiting.contains(rule_name) {
136                // Cycle detected, but we'll just skip it
137                return;
138            }
139
140            visiting.insert(rule_name);
141
142            // Visit dependencies first
143            if let Some(deps) = reverse_deps.get(rule_name) {
144                for dep in deps {
145                    if rule_map.contains_key(dep) {
146                        visit(dep, rule_map, reverse_deps, visited, visiting, sorted);
147                    }
148                }
149            }
150
151            visiting.remove(rule_name);
152            visited.insert(rule_name);
153
154            // Add this rule to sorted list
155            if let Some(&rule) = rule_map.get(rule_name) {
156                sorted.push(rule);
157            }
158        }
159
160        // Visit all rules
161        for rule in rules {
162            visit(
163                rule.name(),
164                &rule_map,
165                &reverse_deps,
166                &mut visited,
167                &mut visiting,
168                &mut sorted,
169            );
170        }
171
172        // Add any rules not in dependency graph
173        for rule in rules {
174            if !sorted.iter().any(|r| r.name() == rule.name()) {
175                sorted.push(rule.as_ref());
176            }
177        }
178
179        sorted
180    }
181
182    /// Apply fixes iteratively until no more fixes are needed or max iterations reached.
183    ///
184    /// This implements a Ruff-inspired fix loop that re-checks ALL rules after each fix
185    /// to detect cascading issues (e.g., MD046 creating code blocks that MD040 needs to fix).
186    ///
187    /// The `file_path` parameter is used to determine per-file flavor overrides. If provided,
188    /// the flavor for creating LintContext will be resolved using `config.get_flavor_for_file()`.
189    pub fn apply_fixes_iterative(
190        &self,
191        rules: &[Box<dyn Rule>],
192        _all_warnings: &[LintWarning], // Kept for API compatibility, but we re-check all rules
193        content: &mut String,
194        config: &Config,
195        max_iterations: usize,
196        file_path: Option<&std::path::Path>,
197    ) -> Result<FixResult, String> {
198        // Use the minimum of max_iterations parameter and MAX_ITERATIONS constant
199        let max_iterations = max_iterations.min(MAX_ITERATIONS);
200
201        // Get optimal rule order based on dependencies
202        let ordered_rules = self.get_optimal_order(rules);
203
204        let mut total_fixed = 0;
205        let mut total_ctx_creations = 0;
206        let mut iterations = 0;
207
208        // History tracks (content_hash, rule_that_produced_this_state).
209        // The initial entry has an empty rule name (no rule produced the initial content).
210        let mut history: Vec<(u64, &str)> = vec![(hash_content(content), "")];
211
212        // Track which rules actually applied fixes
213        let mut fixed_rule_names: HashSet<&str> = HashSet::new();
214
215        // Config rule lists are guaranteed canonical by `Config::canonicalize_rule_lists`,
216        // so a plain string set matches `Rule::name()` directly.
217        let unfixable_rules: HashSet<String> = config.global.unfixable.iter().cloned().collect();
218        let fixable_rules: HashSet<String> = config.global.fixable.iter().cloned().collect();
219        let has_fixable_allowlist = !fixable_rules.is_empty();
220
221        // Per-file-ignores are config-driven, per-file rule exclusions. The
222        // coordinator is the single engine every fix path funnels through, so
223        // resolving them here guarantees `fmt`/fix never rewrites a rule the file
224        // has excluded - no caller can reintroduce issue #707 by forgetting to
225        // pre-filter. Empty when no path is available (e.g. WASM without a path).
226        let ignored_for_file: HashSet<String> = file_path
227            .map(|p| config.get_ignored_rules_for_file(p))
228            .unwrap_or_default();
229
230        // Ruff-style fix loop: keep applying fixes until content stabilizes
231        while iterations < max_iterations {
232            iterations += 1;
233
234            // Create fresh context for this iteration
235            // Use per-file flavor if file_path is provided, otherwise fall back to global flavor
236            let flavor = file_path.map_or_else(|| config.markdown_flavor(), |p| config.get_flavor_for_file(p));
237            let ctx = LintContext::new(content, flavor, file_path.map(std::path::Path::to_path_buf));
238            total_ctx_creations += 1;
239
240            // Inline `rumdl-configure-file` value overrides: when the document carries
241            // inline rule-config overrides, recreate the affected rules from the merged
242            // config so fixes honor them, matching the lint/diagnostics path. Without
243            // this, fix() would run with the base config and could rewrite content the
244            // configured rule considers valid.
245            let recreated_rules: HashMap<String, Box<dyn Rule>> = {
246                let inline_overrides = ctx.inline_config().get_all_rule_configs();
247                if inline_overrides.is_empty() {
248                    HashMap::new()
249                } else {
250                    let merged = config.merge_with_inline_config(ctx.inline_config());
251                    inline_overrides
252                        .keys()
253                        .filter_map(|name| {
254                            crate::rules::create_rule_by_name(name, &merged).map(|rule| (name.clone(), rule))
255                        })
256                        .collect()
257                }
258            };
259
260            let mut any_fix_applied = false;
261            // The rule that applied a fix this iteration (used for cycle reporting).
262            let mut this_iter_rule: &str = "";
263
264            // Check and fix each rule in dependency order
265            for rule in &ordered_rules {
266                // Skip disabled rules
267                if unfixable_rules.contains(rule.name()) {
268                    continue;
269                }
270                if has_fixable_allowlist && !fixable_rules.contains(rule.name()) {
271                    continue;
272                }
273                // Skip rules excluded for this file via [per-file-ignores].
274                if ignored_for_file.contains(rule.name()) {
275                    continue;
276                }
277
278                // Use the inline-config-recreated instance when present so checks and
279                // fixes reflect inline `rumdl-configure-file` overrides; otherwise the
280                // base rule. Rule identity (name) is unchanged either way.
281                let effective_rule: &dyn Rule = recreated_rules.get(rule.name()).map_or(*rule, |r| r.as_ref());
282
283                // Skip rules that indicate they should be skipped (opt-in rules, content-based skipping)
284                if effective_rule.should_skip(&ctx) {
285                    continue;
286                }
287
288                // Check if this rule has any current warnings
289                let Ok(warnings) = effective_rule.check(&ctx) else {
290                    continue;
291                };
292
293                if warnings.is_empty() {
294                    continue;
295                }
296
297                // Filter warnings through inline config to respect disable comments
298                let inline_config = ctx.inline_config();
299                let filtered_warnings =
300                    crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, inline_config, rule.name());
301
302                if filtered_warnings.is_empty() {
303                    continue;
304                }
305
306                // Decide whether to dispatch to rule.fix(). Two paths qualify:
307                //   1. Any non-disabled warning carries an inline Fix (the
308                //      common case — most rules attach per-warning edits).
309                //   2. The rule advertises a fix capability via Rule::fix_capability().
310                //      This is for rules whose fix() rewrites at the document
311                //      level rather than producing per-warning edits (e.g.
312                //      MD046 fence-style normalization, MD076 list spacing).
313                // A rule is skipped only when it has no inline fix AND advertises
314                // no fix capability (Unfixable). Unfixable rules attach no inline
315                // fixes, so in practice they are never dispatched to fix().
316                let has_inline_fix = filtered_warnings.iter().any(|w| w.fix.is_some());
317                let rule_advertises_fix = effective_rule.fix_capability() != FixCapability::Unfixable;
318                if !has_inline_fix && !rule_advertises_fix {
319                    continue;
320                }
321
322                // Apply fix
323                match effective_rule.fix(&ctx) {
324                    Ok(fixed_content) => {
325                        if fixed_content != *content {
326                            *content = fixed_content;
327                            total_fixed += 1;
328                            any_fix_applied = true;
329                            this_iter_rule = rule.name();
330                            fixed_rule_names.insert(rule.name());
331
332                            // Break to re-check all rules with the new content
333                            // This is the key difference from the old approach:
334                            // we always restart from the beginning after a fix
335                            break;
336                        }
337                    }
338                    Err(_) => {
339                        // Error applying fix, continue to next rule
340                        continue;
341                    }
342                }
343            }
344
345            let current_hash = hash_content(content);
346
347            // Check whether this content state has been seen before.
348            if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
349                if cycle_start == history.len() - 1 {
350                    // Content matches the last recorded state: nothing changed this iteration.
351                    return Ok(FixResult {
352                        rules_fixed: total_fixed,
353                        iterations,
354                        context_creations: total_ctx_creations,
355                        fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
356                        converged: true,
357                        conflicting_rules: Vec::new(),
358                        conflict_cycle: Vec::new(),
359                    });
360                } else {
361                    // Content matches an older state: oscillation cycle detected.
362                    // Collect the rules that participate in the cycle.
363                    let conflict_cycle: Vec<String> = history[cycle_start + 1..]
364                        .iter()
365                        .map(|(_, r)| r.to_string())
366                        .chain(std::iter::once(this_iter_rule.to_string()))
367                        .filter(|r| !r.is_empty())
368                        .collect();
369                    let conflicting_rules: Vec<String> = history[cycle_start + 1..]
370                        .iter()
371                        .map(|(_, r)| *r)
372                        .chain(std::iter::once(this_iter_rule))
373                        .filter(|r| !r.is_empty())
374                        .collect::<HashSet<&str>>()
375                        .into_iter()
376                        .map(std::string::ToString::to_string)
377                        .collect();
378                    return Ok(FixResult {
379                        rules_fixed: total_fixed,
380                        iterations,
381                        context_creations: total_ctx_creations,
382                        fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
383                        converged: false,
384                        conflicting_rules,
385                        conflict_cycle,
386                    });
387                }
388            }
389
390            // New state - record it.
391            history.push((current_hash, this_iter_rule));
392
393            // If no fix was applied this iteration, content is stable.
394            if !any_fix_applied {
395                return Ok(FixResult {
396                    rules_fixed: total_fixed,
397                    iterations,
398                    context_creations: total_ctx_creations,
399                    fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
400                    converged: true,
401                    conflicting_rules: Vec::new(),
402                    conflict_cycle: Vec::new(),
403                });
404            }
405        }
406
407        // Hit max iterations without detecting a cycle.
408        Ok(FixResult {
409            rules_fixed: total_fixed,
410            iterations,
411            context_creations: total_ctx_creations,
412            fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
413            converged: false,
414            conflicting_rules: Vec::new(),
415            conflict_cycle: Vec::new(),
416        })
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
424    use std::sync::atomic::{AtomicUsize, Ordering};
425
426    /// Mock rule that checks content and applies fixes based on a condition
427    #[derive(Clone)]
428    struct ConditionalFixRule {
429        name: &'static str,
430        /// Function to check if content has issues
431        check_fn: fn(&str) -> bool,
432        /// Function to fix content
433        fix_fn: fn(&str) -> String,
434    }
435
436    impl Rule for ConditionalFixRule {
437        fn name(&self) -> &'static str {
438            self.name
439        }
440
441        fn check(&self, ctx: &LintContext) -> LintResult {
442            if (self.check_fn)(ctx.content) {
443                Ok(vec![LintWarning {
444                    line: 1,
445                    column: 1,
446                    end_line: 1,
447                    end_column: 1,
448                    message: format!("{} issue found", self.name),
449                    rule_name: Some(self.name.to_string()),
450                    severity: Severity::Error,
451                    fix: Some(Fix::new(0..0, String::new())),
452                }])
453            } else {
454                Ok(vec![])
455            }
456        }
457
458        fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
459            Ok((self.fix_fn)(ctx.content))
460        }
461
462        fn description(&self) -> &'static str {
463            "Conditional fix rule for testing"
464        }
465
466        fn category(&self) -> RuleCategory {
467            RuleCategory::Whitespace
468        }
469
470        fn as_any(&self) -> &dyn std::any::Any {
471            self
472        }
473    }
474
475    // Simple mock rule for basic tests
476    #[derive(Clone)]
477    struct MockRule {
478        name: &'static str,
479        warnings: Vec<LintWarning>,
480        fix_content: String,
481    }
482
483    impl Rule for MockRule {
484        fn name(&self) -> &'static str {
485            self.name
486        }
487
488        fn check(&self, _ctx: &LintContext) -> LintResult {
489            Ok(self.warnings.clone())
490        }
491
492        fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
493            Ok(self.fix_content.clone())
494        }
495
496        fn description(&self) -> &'static str {
497            "Mock rule for testing"
498        }
499
500        fn category(&self) -> RuleCategory {
501            RuleCategory::Whitespace
502        }
503
504        fn as_any(&self) -> &dyn std::any::Any {
505            self
506        }
507    }
508
509    #[test]
510    fn test_dependency_ordering() {
511        let coordinator = FixCoordinator::new();
512
513        let rules: Vec<Box<dyn Rule>> = vec![
514            Box::new(MockRule {
515                name: "MD009",
516                warnings: vec![],
517                fix_content: "".to_string(),
518            }),
519            Box::new(MockRule {
520                name: "MD013",
521                warnings: vec![],
522                fix_content: "".to_string(),
523            }),
524            Box::new(MockRule {
525                name: "MD010",
526                warnings: vec![],
527                fix_content: "".to_string(),
528            }),
529            Box::new(MockRule {
530                name: "MD007",
531                warnings: vec![],
532                fix_content: "".to_string(),
533            }),
534        ];
535
536        let ordered = coordinator.get_optimal_order(&rules);
537        let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
538
539        // MD010 should come before MD007 (dependency)
540        let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
541        let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
542        assert!(md010_idx < md007_idx, "MD010 should come before MD007");
543
544        // MD013 should come before MD009 (dependency)
545        let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
546        let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
547        assert!(md013_idx < md009_idx, "MD013 should come before MD009");
548    }
549
550    #[test]
551    fn test_single_rule_fix() {
552        let coordinator = FixCoordinator::new();
553
554        // Rule that removes "BAD" from content
555        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
556            name: "RemoveBad",
557            check_fn: |content| content.contains("BAD"),
558            fix_fn: |content| content.replace("BAD", "GOOD"),
559        })];
560
561        let mut content = "This is BAD content".to_string();
562        let config = Config::default();
563
564        let result = coordinator
565            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
566            .unwrap();
567
568        assert_eq!(content, "This is GOOD content");
569        assert_eq!(result.rules_fixed, 1);
570        assert!(result.converged);
571    }
572
573    #[test]
574    fn test_cascading_fixes() {
575        // Simulates MD046 -> MD040 cascade:
576        // Rule1: converts "INDENT" to "FENCE" (like MD046 converting indented to fenced)
577        // Rule2: converts "FENCE" to "FENCE_LANG" (like MD040 adding language)
578        let coordinator = FixCoordinator::new();
579
580        let rules: Vec<Box<dyn Rule>> = vec![
581            Box::new(ConditionalFixRule {
582                name: "Rule1_IndentToFence",
583                check_fn: |content| content.contains("INDENT"),
584                fix_fn: |content| content.replace("INDENT", "FENCE"),
585            }),
586            Box::new(ConditionalFixRule {
587                name: "Rule2_FenceToLang",
588                check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
589                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
590            }),
591        ];
592
593        let mut content = "Code: INDENT".to_string();
594        let config = Config::default();
595
596        let result = coordinator
597            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
598            .unwrap();
599
600        // Should reach final state in one run (internally multiple iterations)
601        assert_eq!(content, "Code: FENCE_LANG");
602        assert_eq!(result.rules_fixed, 2);
603        assert!(result.converged);
604        assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
605    }
606
607    #[test]
608    fn test_indirect_cascade() {
609        // Simulates MD022 -> MD046 -> MD040 indirect cascade:
610        // Rule1: adds "BLANK" (like MD022 adding blank line)
611        // Rule2: only triggers if "BLANK" present, converts "CODE" to "FENCE"
612        // Rule3: converts "FENCE" to "FENCE_LANG"
613        let coordinator = FixCoordinator::new();
614
615        let rules: Vec<Box<dyn Rule>> = vec![
616            Box::new(ConditionalFixRule {
617                name: "Rule1_AddBlank",
618                check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
619                fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
620            }),
621            Box::new(ConditionalFixRule {
622                name: "Rule2_CodeToFence",
623                // Only detects CODE as issue if BLANK is present (simulates CommonMark rule)
624                check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
625                fix_fn: |content| content.replace("CODE", "FENCE"),
626            }),
627            Box::new(ConditionalFixRule {
628                name: "Rule3_AddLang",
629                check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
630                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
631            }),
632        ];
633
634        let mut content = "HEADING CODE".to_string();
635        let config = Config::default();
636
637        let result = coordinator
638            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
639            .unwrap();
640
641        // Key assertion: all fixes applied in single run
642        assert_eq!(content, "HEADING BLANK FENCE_LANG");
643        assert_eq!(result.rules_fixed, 3);
644        assert!(result.converged);
645    }
646
647    #[test]
648    fn test_unfixable_rules_skipped() {
649        let coordinator = FixCoordinator::new();
650
651        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
652            name: "MD001",
653            check_fn: |content| content.contains("BAD"),
654            fix_fn: |content| content.replace("BAD", "GOOD"),
655        })];
656
657        let mut content = "BAD content".to_string();
658        let mut config = Config::default();
659        config.global.unfixable = vec!["MD001".to_string()];
660
661        let result = coordinator
662            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
663            .unwrap();
664
665        assert_eq!(content, "BAD content"); // Should not be changed
666        assert_eq!(result.rules_fixed, 0);
667        assert!(result.converged);
668    }
669
670    #[test]
671    fn test_fixable_allowlist() {
672        let coordinator = FixCoordinator::new();
673
674        let rules: Vec<Box<dyn Rule>> = vec![
675            Box::new(ConditionalFixRule {
676                name: "MD001",
677                check_fn: |content| content.contains('A'),
678                fix_fn: |content| content.replace('A', "X"),
679            }),
680            Box::new(ConditionalFixRule {
681                name: "MD002",
682                check_fn: |content| content.contains('B'),
683                fix_fn: |content| content.replace('B', "Y"),
684            }),
685        ];
686
687        let mut content = "AB".to_string();
688        let mut config = Config::default();
689        config.global.fixable = vec!["MD001".to_string()];
690
691        let result = coordinator
692            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
693            .unwrap();
694
695        assert_eq!(content, "XB"); // Only A->X, B unchanged
696        assert_eq!(result.rules_fixed, 1);
697    }
698
699    /// Aliases in `unfixable` (e.g. `"heading-increment"`) must reach
700    /// `apply_fixes_iterative` already canonicalised — the runtime invariant
701    /// enforced by `Config::canonicalize_rule_lists` at every mutation
702    /// boundary (`From<SourcedConfig> for Config`, LSP `apply_lsp_settings_*`,
703    /// WASM `to_config_with_warnings`). The fix coordinator therefore matches
704    /// against `Rule::name()` with plain string equality.
705    #[test]
706    fn test_unfixable_rules_resolved_from_alias() {
707        let coordinator = FixCoordinator::new();
708
709        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
710            name: "MD001",
711            check_fn: |content| content.contains("BAD"),
712            fix_fn: |content| content.replace("BAD", "GOOD"),
713        })];
714
715        let mut content = "BAD content".to_string();
716        let mut config = Config::default();
717        // Caller writes the alias…
718        config.global.unfixable = vec!["heading-increment".to_string()];
719        // …and the boundary canonicalises it to "MD001" before lint/fix sees it.
720        config.canonicalize_rule_lists();
721
722        let result = coordinator
723            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
724            .unwrap();
725
726        assert_eq!(content, "BAD content");
727        assert_eq!(result.rules_fixed, 0);
728        assert!(result.converged);
729    }
730
731    /// Counterpart to `test_unfixable_rules_resolved_from_alias` for the
732    /// fixable allowlist. Same invariant: callers may write aliases, but the
733    /// boundary canonicalises before the fix coordinator sees the config.
734    #[test]
735    fn test_fixable_allowlist_resolved_from_alias() {
736        let coordinator = FixCoordinator::new();
737
738        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
739            name: "MD001",
740            check_fn: |content| content.contains("BAD"),
741            fix_fn: |content| content.replace("BAD", "GOOD"),
742        })];
743
744        let mut content = "BAD content".to_string();
745        let mut config = Config::default();
746        config.global.fixable = vec!["heading-increment".to_string()];
747        config.canonicalize_rule_lists();
748
749        let result = coordinator
750            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
751            .unwrap();
752
753        assert_eq!(content, "GOOD content");
754        assert_eq!(result.rules_fixed, 1);
755    }
756
757    #[test]
758    fn test_max_iterations_limit() {
759        let coordinator = FixCoordinator::new();
760
761        // Rule that always changes content (pathological case)
762        static COUNTER: AtomicUsize = AtomicUsize::new(0);
763
764        #[derive(Clone)]
765        struct AlwaysChangeRule;
766        impl Rule for AlwaysChangeRule {
767            fn name(&self) -> &'static str {
768                "AlwaysChange"
769            }
770            fn check(&self, _: &LintContext) -> LintResult {
771                Ok(vec![LintWarning {
772                    line: 1,
773                    column: 1,
774                    end_line: 1,
775                    end_column: 1,
776                    message: "Always".to_string(),
777                    rule_name: Some("AlwaysChange".to_string()),
778                    severity: Severity::Error,
779                    fix: Some(Fix::new(0..0, String::new())),
780                }])
781            }
782            fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
783                COUNTER.fetch_add(1, Ordering::SeqCst);
784                Ok(format!("{}x", ctx.content))
785            }
786            fn description(&self) -> &'static str {
787                "Always changes"
788            }
789            fn category(&self) -> RuleCategory {
790                RuleCategory::Whitespace
791            }
792            fn as_any(&self) -> &dyn std::any::Any {
793                self
794            }
795        }
796
797        COUNTER.store(0, Ordering::SeqCst);
798        let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
799
800        let mut content = "test".to_string();
801        let config = Config::default();
802
803        let result = coordinator
804            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
805            .unwrap();
806
807        // Should stop at max iterations
808        assert_eq!(result.iterations, 5);
809        assert!(!result.converged);
810        assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
811    }
812
813    #[test]
814    fn test_empty_rules() {
815        let coordinator = FixCoordinator::new();
816        let rules: Vec<Box<dyn Rule>> = vec![];
817
818        let mut content = "unchanged".to_string();
819        let config = Config::default();
820
821        let result = coordinator
822            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
823            .unwrap();
824
825        assert_eq!(result.rules_fixed, 0);
826        assert_eq!(result.iterations, 1);
827        assert!(result.converged);
828        assert_eq!(content, "unchanged");
829    }
830
831    #[test]
832    fn test_no_warnings_no_changes() {
833        let coordinator = FixCoordinator::new();
834
835        // Rule that finds no issues
836        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
837            name: "NoIssues",
838            check_fn: |_| false, // Never finds issues
839            fix_fn: |content| content.to_string(),
840        })];
841
842        let mut content = "clean content".to_string();
843        let config = Config::default();
844
845        let result = coordinator
846            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
847            .unwrap();
848
849        assert_eq!(content, "clean content");
850        assert_eq!(result.rules_fixed, 0);
851        assert!(result.converged);
852    }
853
854    #[test]
855    fn test_oscillation_detection() {
856        // Two rules that fight each other: Rule A changes "foo" → "bar", Rule B changes "bar" → "foo".
857        // The fix loop should detect this as an oscillation cycle and stop early with
858        // conflicting_rules populated rather than running all 100 iterations.
859        let coordinator = FixCoordinator::new();
860
861        let rules: Vec<Box<dyn Rule>> = vec![
862            Box::new(ConditionalFixRule {
863                name: "RuleA",
864                check_fn: |content| content.contains("foo"),
865                fix_fn: |content| content.replace("foo", "bar"),
866            }),
867            Box::new(ConditionalFixRule {
868                name: "RuleB",
869                check_fn: |content| content.contains("bar"),
870                fix_fn: |content| content.replace("bar", "foo"),
871            }),
872        ];
873
874        let mut content = "foo".to_string();
875        let config = Config::default();
876
877        let result = coordinator
878            .apply_fixes_iterative(&rules, &[], &mut content, &config, 100, None)
879            .unwrap();
880
881        // Should detect the cycle quickly, not burn through all 100 iterations.
882        assert!(!result.converged, "Should not converge in an oscillating pair");
883        assert!(
884            result.iterations < 10,
885            "Cycle detection should stop well before max_iterations (got {})",
886            result.iterations
887        );
888
889        // Both conflicting rules should be identified.
890        let mut conflicting = result.conflicting_rules.clone();
891        conflicting.sort();
892        assert_eq!(
893            conflicting,
894            vec!["RuleA".to_string(), "RuleB".to_string()],
895            "Both oscillating rules must be reported"
896        );
897        assert_eq!(
898            result.conflict_cycle,
899            vec!["RuleA".to_string(), "RuleB".to_string()],
900            "Cycle should preserve the observed application order"
901        );
902    }
903
904    #[test]
905    fn test_cyclic_dependencies_handled() {
906        let mut coordinator = FixCoordinator::new();
907
908        // Create a cycle: A -> B -> C -> A
909        coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
910        coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
911        coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
912
913        let rules: Vec<Box<dyn Rule>> = vec![
914            Box::new(MockRule {
915                name: "RuleA",
916                warnings: vec![],
917                fix_content: "".to_string(),
918            }),
919            Box::new(MockRule {
920                name: "RuleB",
921                warnings: vec![],
922                fix_content: "".to_string(),
923            }),
924            Box::new(MockRule {
925                name: "RuleC",
926                warnings: vec![],
927                fix_content: "".to_string(),
928            }),
929        ];
930
931        // Should not panic or infinite loop
932        let ordered = coordinator.get_optimal_order(&rules);
933
934        // Should return all rules despite cycle
935        assert_eq!(ordered.len(), 3);
936    }
937
938    #[test]
939    fn test_fix_is_idempotent() {
940        // This is the key test for issue #271
941        let coordinator = FixCoordinator::new();
942
943        let rules: Vec<Box<dyn Rule>> = vec![
944            Box::new(ConditionalFixRule {
945                name: "Rule1",
946                check_fn: |content| content.contains('A'),
947                fix_fn: |content| content.replace('A', "B"),
948            }),
949            Box::new(ConditionalFixRule {
950                name: "Rule2",
951                check_fn: |content| content.contains('B') && !content.contains('C'),
952                fix_fn: |content| content.replace('B', "BC"),
953            }),
954        ];
955
956        let config = Config::default();
957
958        // First run
959        let mut content1 = "A".to_string();
960        let result1 = coordinator
961            .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
962            .unwrap();
963
964        // Second run on same final content
965        let mut content2 = content1.clone();
966        let result2 = coordinator
967            .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
968            .unwrap();
969
970        // Should be identical (idempotent)
971        assert_eq!(content1, content2);
972        assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
973        assert!(result1.converged);
974        assert!(result2.converged);
975    }
976
977    #[test]
978    fn test_apply_fixes_collapses_double_space_without_inline_override() {
979        // Control: MD064 actually rewrites this content, so the override test below
980        // is not vacuous - without an inline override the double space is collapsed.
981        let mut content = String::from("`<svg>`.  Fortunately\n");
982        let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
983        FixCoordinator::new()
984            .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
985            .unwrap();
986        assert_eq!(
987            content, "`<svg>`. Fortunately\n",
988            "MD064 collapses the sentence double space when not overridden"
989        );
990    }
991
992    #[test]
993    fn test_per_file_ignores_skipped_even_with_unfiltered_rules() {
994        // The coordinator is the single engine every fix path funnels through. Handed
995        // the UNFILTERED rule set plus a file path, it must still skip any rule the
996        // path excludes via [per-file-ignores] - so no caller can reintroduce #707 by
997        // forgetting to pre-filter.
998        let coordinator = FixCoordinator::new();
999        let rules: Vec<Box<dyn Rule>> = vec![
1000            Box::new(ConditionalFixRule {
1001                name: "MD004",
1002                check_fn: |c| c.contains('*'),
1003                fix_fn: |c| c.replace('*', "-"),
1004            }),
1005            Box::new(ConditionalFixRule {
1006                name: "MD032",
1007                check_fn: |c| c.contains("PARENT"),
1008                fix_fn: |c| c.replace("PARENT", "parent"),
1009            }),
1010        ];
1011
1012        let mut config = Config::default();
1013        config
1014            .per_file_ignores
1015            .insert("slides/**/*.md".to_string(), vec!["MD004".to_string()]);
1016        config.canonicalize_rule_lists();
1017
1018        // Path matches the ignore glob: MD004 must be skipped, MD032 must still apply.
1019        let mut content = "* PARENT".to_string();
1020        let result = coordinator
1021            .apply_fixes_iterative(
1022                &rules,
1023                &[],
1024                &mut content,
1025                &config,
1026                10,
1027                Some(std::path::Path::new("slides/deck.md")),
1028            )
1029            .unwrap();
1030        assert_eq!(
1031            content, "* parent",
1032            "MD032 applied, MD004 (`*` -> `-`) skipped for slides/**"
1033        );
1034        assert!(result.fixed_rule_names.contains("MD032"));
1035        assert!(!result.fixed_rule_names.contains("MD004"));
1036
1037        // Control: a path NOT matching the glob applies both rules, proving the skip
1038        // above is driven by per-file-ignores and not something else.
1039        let mut other = "* PARENT".to_string();
1040        coordinator
1041            .apply_fixes_iterative(
1042                &rules,
1043                &[],
1044                &mut other,
1045                &config,
1046                10,
1047                Some(std::path::Path::new("docs/other.md")),
1048            )
1049            .unwrap();
1050        assert_eq!(
1051            other, "- parent",
1052            "both rules apply when the path is not per-file-ignored"
1053        );
1054    }
1055
1056    #[test]
1057    fn test_apply_fixes_honors_inline_configure_file_overrides() {
1058        // A document that relaxes a rule via an inline `rumdl-configure-file` override
1059        // must survive the fix coordinator unchanged: fixes have to honor inline value
1060        // overrides the same way lint/diagnostics do, otherwise "fix" rewrites content
1061        // the configured rule considers valid.
1062        let mut content = String::from(
1063            "<!-- rumdl-configure-file { \"MD064\": { \"allow-sentence-double-space\": true } } -->\n\n`<svg>`.  Fortunately\n",
1064        );
1065        let original = content.clone();
1066        let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1067        FixCoordinator::new()
1068            .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1069            .unwrap();
1070        assert_eq!(
1071            content, original,
1072            "inline rumdl-configure-file override (allow-sentence-double-space) must prevent the MD064 fix"
1073        );
1074    }
1075}