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        self.apply_fixes_iterative_with_paths(
199            rules,
200            all_warnings,
201            content,
202            config,
203            max_iterations,
204            crate::DocumentPaths::same(file_path),
205        )
206    }
207
208    /// Apply fixes with separate paths for configuration matching and rule filesystem access.
209    ///
210    /// Native file adapters pass the same path for both. Virtual adapters can use a
211    /// logical `config_path` for per-file flavor and ignores while leaving
212    /// `source_file` unset so filesystem-dependent rules stay disabled.
213    pub fn apply_fixes_iterative_with_paths(
214        &self,
215        rules: &[Box<dyn Rule>],
216        _all_warnings: &[LintWarning],
217        content: &mut String,
218        config: &Config,
219        max_iterations: usize,
220        paths: crate::DocumentPaths<'_>,
221    ) -> Result<FixResult, String> {
222        // Use the minimum of max_iterations parameter and MAX_ITERATIONS constant
223        let max_iterations = max_iterations.min(MAX_ITERATIONS);
224
225        // Get optimal rule order based on dependencies
226        let ordered_rules = self.get_optimal_order(rules);
227
228        let mut total_fixed = 0;
229        let mut total_ctx_creations = 0;
230        let mut iterations = 0;
231
232        // History tracks (content_hash, rule_that_produced_this_state).
233        // The initial entry has an empty rule name (no rule produced the initial content).
234        let mut history: Vec<(u64, &str)> = vec![(hash_content(content), "")];
235
236        // Track which rules actually applied fixes
237        let mut fixed_rule_names: HashSet<&str> = HashSet::new();
238
239        // Config rule lists are guaranteed canonical by `Config::canonicalize_rule_lists`,
240        // so a plain string set matches `Rule::name()` directly.
241        let unfixable_rules: HashSet<String> = config.global.unfixable.iter().cloned().collect();
242        let fixable_rules: HashSet<String> = config.global.fixable.iter().cloned().collect();
243        let has_fixable_allowlist = !fixable_rules.is_empty();
244
245        // Per-file-ignores are config-driven, per-file rule exclusions. The
246        // coordinator is the single engine every fix path funnels through, so
247        // resolving them here guarantees `fmt`/fix never rewrites a rule the file
248        // has excluded - no caller can reintroduce issue #707 by forgetting to
249        // pre-filter. Empty when no path is available (e.g. WASM without a path).
250        let ignored_for_file: HashSet<String> = paths
251            .config_path
252            .map(|p| config.get_ignored_rules_for_file(p))
253            .unwrap_or_default();
254
255        // Ruff-style fix loop: keep applying fixes until content stabilizes
256        while iterations < max_iterations {
257            iterations += 1;
258
259            // Create fresh context for this iteration
260            // Use per-file flavor if file_path is provided, otherwise fall back to global flavor
261            let flavor = paths
262                .config_path
263                .map_or_else(|| config.markdown_flavor(), |path| config.get_flavor_for_file(path));
264            let ctx = LintContext::new(content, flavor, paths.source_file.map(std::path::Path::to_path_buf));
265            total_ctx_creations += 1;
266
267            // Inline `rumdl-configure-file` value overrides: when the document carries
268            // inline rule-config overrides, recreate the affected rules from the merged
269            // config so fixes honor them, matching the lint/diagnostics path. Without
270            // this, fix() would run with the base config and could rewrite content the
271            // configured rule considers valid.
272            let recreated_rules: HashMap<String, Box<dyn Rule>> = {
273                let inline_overrides = ctx.inline_config().get_all_rule_configs();
274                if inline_overrides.is_empty() {
275                    HashMap::new()
276                } else {
277                    let merged = config.merge_with_inline_config(ctx.inline_config());
278                    inline_overrides
279                        .keys()
280                        .filter_map(|name| {
281                            crate::rules::create_rule_by_name(name, &merged).map(|rule| (name.clone(), rule))
282                        })
283                        .collect()
284                }
285            };
286
287            let mut any_fix_applied = false;
288            // The rule that applied a fix this iteration (used for cycle reporting).
289            let mut this_iter_rule: &str = "";
290
291            // Check and fix each rule in dependency order
292            for rule in &ordered_rules {
293                // Skip disabled rules
294                if unfixable_rules.contains(rule.name()) {
295                    continue;
296                }
297                if has_fixable_allowlist && !fixable_rules.contains(rule.name()) {
298                    continue;
299                }
300                // Skip rules excluded for this file via [per-file-ignores].
301                if ignored_for_file.contains(rule.name()) {
302                    continue;
303                }
304
305                // Use the inline-config-recreated instance when present so checks and
306                // fixes reflect inline `rumdl-configure-file` overrides; otherwise the
307                // base rule. Rule identity (name) is unchanged either way.
308                let effective_rule: &dyn Rule = recreated_rules.get(rule.name()).map_or(*rule, |r| r.as_ref());
309
310                // Skip rules that indicate they should be skipped (opt-in rules, content-based skipping)
311                if effective_rule.should_skip(&ctx) {
312                    continue;
313                }
314
315                // Check if this rule has any current warnings
316                let Ok(warnings) = effective_rule.check(&ctx) else {
317                    continue;
318                };
319
320                if warnings.is_empty() {
321                    continue;
322                }
323
324                // Filter warnings through inline config to respect disable comments
325                let inline_config = ctx.inline_config();
326                let filtered_warnings =
327                    crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, inline_config, rule.name());
328
329                if filtered_warnings.is_empty() {
330                    continue;
331                }
332
333                // Decide whether to dispatch to rule.fix(). Two paths qualify:
334                //   1. Any non-disabled warning carries an inline Fix (the
335                //      common case — most rules attach per-warning edits).
336                //   2. The rule advertises a fix capability via Rule::fix_capability().
337                //      This is for rules whose fix() rewrites at the document
338                //      level rather than producing per-warning edits (e.g.
339                //      MD046 fence-style normalization, MD076 list spacing).
340                // A rule is skipped only when it has no inline fix AND advertises
341                // no fix capability (Unfixable). Unfixable rules attach no inline
342                // fixes, so in practice they are never dispatched to fix().
343                let has_inline_fix = filtered_warnings.iter().any(|w| w.fix.is_some());
344                let rule_advertises_fix = effective_rule.fix_capability() != FixCapability::Unfixable;
345                if !has_inline_fix && !rule_advertises_fix {
346                    continue;
347                }
348
349                // Apply fix
350                match effective_rule.fix(&ctx) {
351                    Ok(fixed_content) => {
352                        if fixed_content != *content {
353                            *content = fixed_content;
354                            total_fixed += 1;
355                            any_fix_applied = true;
356                            this_iter_rule = rule.name();
357                            fixed_rule_names.insert(rule.name());
358
359                            // Break to re-check all rules with the new content
360                            // This is the key difference from the old approach:
361                            // we always restart from the beginning after a fix
362                            break;
363                        }
364                    }
365                    Err(_) => {
366                        // Error applying fix, continue to next rule
367                        continue;
368                    }
369                }
370            }
371
372            let current_hash = hash_content(content);
373
374            // Check whether this content state has been seen before.
375            if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
376                if cycle_start == history.len() - 1 {
377                    // Content matches the last recorded state: nothing changed this iteration.
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: true,
384                        conflicting_rules: Vec::new(),
385                        conflict_cycle: Vec::new(),
386                    });
387                } else {
388                    // Content matches an older state: oscillation cycle detected.
389                    // Collect the rules that participate in the cycle.
390                    let conflict_cycle: Vec<String> = history[cycle_start + 1..]
391                        .iter()
392                        .map(|(_, r)| r.to_string())
393                        .chain(std::iter::once(this_iter_rule.to_string()))
394                        .filter(|r| !r.is_empty())
395                        .collect();
396                    let conflicting_rules: Vec<String> = history[cycle_start + 1..]
397                        .iter()
398                        .map(|(_, r)| *r)
399                        .chain(std::iter::once(this_iter_rule))
400                        .filter(|r| !r.is_empty())
401                        .collect::<HashSet<&str>>()
402                        .into_iter()
403                        .map(std::string::ToString::to_string)
404                        .collect();
405                    return Ok(FixResult {
406                        rules_fixed: total_fixed,
407                        iterations,
408                        context_creations: total_ctx_creations,
409                        fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
410                        converged: false,
411                        conflicting_rules,
412                        conflict_cycle,
413                    });
414                }
415            }
416
417            // New state - record it.
418            history.push((current_hash, this_iter_rule));
419
420            // If no fix was applied this iteration, content is stable.
421            if !any_fix_applied {
422                return Ok(FixResult {
423                    rules_fixed: total_fixed,
424                    iterations,
425                    context_creations: total_ctx_creations,
426                    fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
427                    converged: true,
428                    conflicting_rules: Vec::new(),
429                    conflict_cycle: Vec::new(),
430                });
431            }
432        }
433
434        // Hit max iterations without detecting a cycle.
435        Ok(FixResult {
436            rules_fixed: total_fixed,
437            iterations,
438            context_creations: total_ctx_creations,
439            fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
440            converged: false,
441            conflicting_rules: Vec::new(),
442            conflict_cycle: Vec::new(),
443        })
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
451    use std::sync::atomic::{AtomicUsize, Ordering};
452
453    /// Mock rule that checks content and applies fixes based on a condition
454    #[derive(Clone)]
455    struct ConditionalFixRule {
456        name: &'static str,
457        /// Function to check if content has issues
458        check_fn: fn(&str) -> bool,
459        /// Function to fix content
460        fix_fn: fn(&str) -> String,
461    }
462
463    impl Rule for ConditionalFixRule {
464        fn name(&self) -> &'static str {
465            self.name
466        }
467
468        fn check(&self, ctx: &LintContext) -> LintResult {
469            if (self.check_fn)(ctx.content) {
470                Ok(vec![LintWarning {
471                    line: 1,
472                    column: 1,
473                    end_line: 1,
474                    end_column: 1,
475                    message: format!("{} issue found", self.name),
476                    rule_name: Some(self.name.to_string()),
477                    severity: Severity::Error,
478                    fix: Some(Fix::new(0..0, String::new())),
479                }])
480            } else {
481                Ok(vec![])
482            }
483        }
484
485        fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
486            Ok((self.fix_fn)(ctx.content))
487        }
488
489        fn description(&self) -> &'static str {
490            "Conditional fix rule for testing"
491        }
492
493        fn category(&self) -> RuleCategory {
494            RuleCategory::Whitespace
495        }
496
497        fn as_any(&self) -> &dyn std::any::Any {
498            self
499        }
500    }
501
502    // Simple mock rule for basic tests
503    #[derive(Clone)]
504    struct MockRule {
505        name: &'static str,
506        warnings: Vec<LintWarning>,
507        fix_content: String,
508    }
509
510    impl Rule for MockRule {
511        fn name(&self) -> &'static str {
512            self.name
513        }
514
515        fn check(&self, _ctx: &LintContext) -> LintResult {
516            Ok(self.warnings.clone())
517        }
518
519        fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
520            Ok(self.fix_content.clone())
521        }
522
523        fn description(&self) -> &'static str {
524            "Mock rule for testing"
525        }
526
527        fn category(&self) -> RuleCategory {
528            RuleCategory::Whitespace
529        }
530
531        fn as_any(&self) -> &dyn std::any::Any {
532            self
533        }
534    }
535
536    #[test]
537    fn test_dependency_ordering() {
538        let coordinator = FixCoordinator::new();
539
540        let rules: Vec<Box<dyn Rule>> = vec![
541            Box::new(MockRule {
542                name: "MD009",
543                warnings: vec![],
544                fix_content: "".to_string(),
545            }),
546            Box::new(MockRule {
547                name: "MD013",
548                warnings: vec![],
549                fix_content: "".to_string(),
550            }),
551            Box::new(MockRule {
552                name: "MD010",
553                warnings: vec![],
554                fix_content: "".to_string(),
555            }),
556            Box::new(MockRule {
557                name: "MD007",
558                warnings: vec![],
559                fix_content: "".to_string(),
560            }),
561        ];
562
563        let ordered = coordinator.get_optimal_order(&rules);
564        let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
565
566        // MD010 should come before MD007 (dependency)
567        let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
568        let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
569        assert!(md010_idx < md007_idx, "MD010 should come before MD007");
570
571        // MD013 should come before MD009 (dependency)
572        let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
573        let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
574        assert!(md013_idx < md009_idx, "MD013 should come before MD009");
575    }
576
577    #[test]
578    fn test_single_rule_fix() {
579        let coordinator = FixCoordinator::new();
580
581        // Rule that removes "BAD" from content
582        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
583            name: "RemoveBad",
584            check_fn: |content| content.contains("BAD"),
585            fix_fn: |content| content.replace("BAD", "GOOD"),
586        })];
587
588        let mut content = "This is BAD content".to_string();
589        let config = Config::default();
590
591        let result = coordinator
592            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
593            .unwrap();
594
595        assert_eq!(content, "This is GOOD content");
596        assert_eq!(result.rules_fixed, 1);
597        assert!(result.converged);
598    }
599
600    #[test]
601    fn test_cascading_fixes() {
602        // Simulates MD046 -> MD040 cascade:
603        // Rule1: converts "INDENT" to "FENCE" (like MD046 converting indented to fenced)
604        // Rule2: converts "FENCE" to "FENCE_LANG" (like MD040 adding language)
605        let coordinator = FixCoordinator::new();
606
607        let rules: Vec<Box<dyn Rule>> = vec![
608            Box::new(ConditionalFixRule {
609                name: "Rule1_IndentToFence",
610                check_fn: |content| content.contains("INDENT"),
611                fix_fn: |content| content.replace("INDENT", "FENCE"),
612            }),
613            Box::new(ConditionalFixRule {
614                name: "Rule2_FenceToLang",
615                check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
616                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
617            }),
618        ];
619
620        let mut content = "Code: INDENT".to_string();
621        let config = Config::default();
622
623        let result = coordinator
624            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
625            .unwrap();
626
627        // Should reach final state in one run (internally multiple iterations)
628        assert_eq!(content, "Code: FENCE_LANG");
629        assert_eq!(result.rules_fixed, 2);
630        assert!(result.converged);
631        assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
632    }
633
634    #[test]
635    fn test_indirect_cascade() {
636        // Simulates MD022 -> MD046 -> MD040 indirect cascade:
637        // Rule1: adds "BLANK" (like MD022 adding blank line)
638        // Rule2: only triggers if "BLANK" present, converts "CODE" to "FENCE"
639        // Rule3: converts "FENCE" to "FENCE_LANG"
640        let coordinator = FixCoordinator::new();
641
642        let rules: Vec<Box<dyn Rule>> = vec![
643            Box::new(ConditionalFixRule {
644                name: "Rule1_AddBlank",
645                check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
646                fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
647            }),
648            Box::new(ConditionalFixRule {
649                name: "Rule2_CodeToFence",
650                // Only detects CODE as issue if BLANK is present (simulates CommonMark rule)
651                check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
652                fix_fn: |content| content.replace("CODE", "FENCE"),
653            }),
654            Box::new(ConditionalFixRule {
655                name: "Rule3_AddLang",
656                check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
657                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
658            }),
659        ];
660
661        let mut content = "HEADING CODE".to_string();
662        let config = Config::default();
663
664        let result = coordinator
665            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
666            .unwrap();
667
668        // Key assertion: all fixes applied in single run
669        assert_eq!(content, "HEADING BLANK FENCE_LANG");
670        assert_eq!(result.rules_fixed, 3);
671        assert!(result.converged);
672    }
673
674    #[test]
675    fn test_unfixable_rules_skipped() {
676        let coordinator = FixCoordinator::new();
677
678        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
679            name: "MD001",
680            check_fn: |content| content.contains("BAD"),
681            fix_fn: |content| content.replace("BAD", "GOOD"),
682        })];
683
684        let mut content = "BAD content".to_string();
685        let mut config = Config::default();
686        config.global.unfixable = vec!["MD001".to_string()];
687
688        let result = coordinator
689            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
690            .unwrap();
691
692        assert_eq!(content, "BAD content"); // Should not be changed
693        assert_eq!(result.rules_fixed, 0);
694        assert!(result.converged);
695    }
696
697    #[test]
698    fn test_fixable_allowlist() {
699        let coordinator = FixCoordinator::new();
700
701        let rules: Vec<Box<dyn Rule>> = vec![
702            Box::new(ConditionalFixRule {
703                name: "MD001",
704                check_fn: |content| content.contains('A'),
705                fix_fn: |content| content.replace('A', "X"),
706            }),
707            Box::new(ConditionalFixRule {
708                name: "MD002",
709                check_fn: |content| content.contains('B'),
710                fix_fn: |content| content.replace('B', "Y"),
711            }),
712        ];
713
714        let mut content = "AB".to_string();
715        let mut config = Config::default();
716        config.global.fixable = vec!["MD001".to_string()];
717
718        let result = coordinator
719            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
720            .unwrap();
721
722        assert_eq!(content, "XB"); // Only A->X, B unchanged
723        assert_eq!(result.rules_fixed, 1);
724    }
725
726    /// Aliases in `unfixable` (e.g. `"heading-increment"`) must reach
727    /// `apply_fixes_iterative` already canonicalised — the runtime invariant
728    /// enforced by `Config::canonicalize_rule_lists` at every mutation
729    /// boundary (`From<SourcedConfig> for Config`, LSP `apply_lsp_settings_*`,
730    /// WASM `to_config_with_warnings`). The fix coordinator therefore matches
731    /// against `Rule::name()` with plain string equality.
732    #[test]
733    fn test_unfixable_rules_resolved_from_alias() {
734        let coordinator = FixCoordinator::new();
735
736        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
737            name: "MD001",
738            check_fn: |content| content.contains("BAD"),
739            fix_fn: |content| content.replace("BAD", "GOOD"),
740        })];
741
742        let mut content = "BAD content".to_string();
743        let mut config = Config::default();
744        // Caller writes the alias…
745        config.global.unfixable = vec!["heading-increment".to_string()];
746        // …and the boundary canonicalises it to "MD001" before lint/fix sees it.
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, "BAD content");
754        assert_eq!(result.rules_fixed, 0);
755        assert!(result.converged);
756    }
757
758    /// Counterpart to `test_unfixable_rules_resolved_from_alias` for the
759    /// fixable allowlist. Same invariant: callers may write aliases, but the
760    /// boundary canonicalises before the fix coordinator sees the config.
761    #[test]
762    fn test_fixable_allowlist_resolved_from_alias() {
763        let coordinator = FixCoordinator::new();
764
765        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
766            name: "MD001",
767            check_fn: |content| content.contains("BAD"),
768            fix_fn: |content| content.replace("BAD", "GOOD"),
769        })];
770
771        let mut content = "BAD content".to_string();
772        let mut config = Config::default();
773        config.global.fixable = vec!["heading-increment".to_string()];
774        config.canonicalize_rule_lists();
775
776        let result = coordinator
777            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
778            .unwrap();
779
780        assert_eq!(content, "GOOD content");
781        assert_eq!(result.rules_fixed, 1);
782    }
783
784    #[test]
785    fn test_max_iterations_limit() {
786        let coordinator = FixCoordinator::new();
787
788        // Rule that always changes content (pathological case)
789        static COUNTER: AtomicUsize = AtomicUsize::new(0);
790
791        #[derive(Clone)]
792        struct AlwaysChangeRule;
793        impl Rule for AlwaysChangeRule {
794            fn name(&self) -> &'static str {
795                "AlwaysChange"
796            }
797            fn check(&self, _: &LintContext) -> LintResult {
798                Ok(vec![LintWarning {
799                    line: 1,
800                    column: 1,
801                    end_line: 1,
802                    end_column: 1,
803                    message: "Always".to_string(),
804                    rule_name: Some("AlwaysChange".to_string()),
805                    severity: Severity::Error,
806                    fix: Some(Fix::new(0..0, String::new())),
807                }])
808            }
809            fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
810                COUNTER.fetch_add(1, Ordering::SeqCst);
811                Ok(format!("{}x", ctx.content))
812            }
813            fn description(&self) -> &'static str {
814                "Always changes"
815            }
816            fn category(&self) -> RuleCategory {
817                RuleCategory::Whitespace
818            }
819            fn as_any(&self) -> &dyn std::any::Any {
820                self
821            }
822        }
823
824        COUNTER.store(0, Ordering::SeqCst);
825        let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
826
827        let mut content = "test".to_string();
828        let config = Config::default();
829
830        let result = coordinator
831            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
832            .unwrap();
833
834        // Should stop at max iterations
835        assert_eq!(result.iterations, 5);
836        assert!(!result.converged);
837        assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
838    }
839
840    #[test]
841    fn test_empty_rules() {
842        let coordinator = FixCoordinator::new();
843        let rules: Vec<Box<dyn Rule>> = vec![];
844
845        let mut content = "unchanged".to_string();
846        let config = Config::default();
847
848        let result = coordinator
849            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
850            .unwrap();
851
852        assert_eq!(result.rules_fixed, 0);
853        assert_eq!(result.iterations, 1);
854        assert!(result.converged);
855        assert_eq!(content, "unchanged");
856    }
857
858    #[test]
859    fn test_no_warnings_no_changes() {
860        let coordinator = FixCoordinator::new();
861
862        // Rule that finds no issues
863        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
864            name: "NoIssues",
865            check_fn: |_| false, // Never finds issues
866            fix_fn: |content| content.to_string(),
867        })];
868
869        let mut content = "clean content".to_string();
870        let config = Config::default();
871
872        let result = coordinator
873            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
874            .unwrap();
875
876        assert_eq!(content, "clean content");
877        assert_eq!(result.rules_fixed, 0);
878        assert!(result.converged);
879    }
880
881    #[test]
882    fn test_oscillation_detection() {
883        // Two rules that fight each other: Rule A changes "foo" → "bar", Rule B changes "bar" → "foo".
884        // The fix loop should detect this as an oscillation cycle and stop early with
885        // conflicting_rules populated rather than running all 100 iterations.
886        let coordinator = FixCoordinator::new();
887
888        let rules: Vec<Box<dyn Rule>> = vec![
889            Box::new(ConditionalFixRule {
890                name: "RuleA",
891                check_fn: |content| content.contains("foo"),
892                fix_fn: |content| content.replace("foo", "bar"),
893            }),
894            Box::new(ConditionalFixRule {
895                name: "RuleB",
896                check_fn: |content| content.contains("bar"),
897                fix_fn: |content| content.replace("bar", "foo"),
898            }),
899        ];
900
901        let mut content = "foo".to_string();
902        let config = Config::default();
903
904        let result = coordinator
905            .apply_fixes_iterative(&rules, &[], &mut content, &config, 100, None)
906            .unwrap();
907
908        // Should detect the cycle quickly, not burn through all 100 iterations.
909        assert!(!result.converged, "Should not converge in an oscillating pair");
910        assert!(
911            result.iterations < 10,
912            "Cycle detection should stop well before max_iterations (got {})",
913            result.iterations
914        );
915
916        // Both conflicting rules should be identified.
917        let mut conflicting = result.conflicting_rules.clone();
918        conflicting.sort();
919        assert_eq!(
920            conflicting,
921            vec!["RuleA".to_string(), "RuleB".to_string()],
922            "Both oscillating rules must be reported"
923        );
924        assert_eq!(
925            result.conflict_cycle,
926            vec!["RuleA".to_string(), "RuleB".to_string()],
927            "Cycle should preserve the observed application order"
928        );
929    }
930
931    #[test]
932    fn test_cyclic_dependencies_handled() {
933        let mut coordinator = FixCoordinator::new();
934
935        // Create a cycle: A -> B -> C -> A
936        coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
937        coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
938        coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
939
940        let rules: Vec<Box<dyn Rule>> = vec![
941            Box::new(MockRule {
942                name: "RuleA",
943                warnings: vec![],
944                fix_content: "".to_string(),
945            }),
946            Box::new(MockRule {
947                name: "RuleB",
948                warnings: vec![],
949                fix_content: "".to_string(),
950            }),
951            Box::new(MockRule {
952                name: "RuleC",
953                warnings: vec![],
954                fix_content: "".to_string(),
955            }),
956        ];
957
958        // Should not panic or infinite loop
959        let ordered = coordinator.get_optimal_order(&rules);
960
961        // Should return all rules despite cycle
962        assert_eq!(ordered.len(), 3);
963    }
964
965    #[test]
966    fn test_fix_is_idempotent() {
967        // This is the key test for issue #271
968        let coordinator = FixCoordinator::new();
969
970        let rules: Vec<Box<dyn Rule>> = vec![
971            Box::new(ConditionalFixRule {
972                name: "Rule1",
973                check_fn: |content| content.contains('A'),
974                fix_fn: |content| content.replace('A', "B"),
975            }),
976            Box::new(ConditionalFixRule {
977                name: "Rule2",
978                check_fn: |content| content.contains('B') && !content.contains('C'),
979                fix_fn: |content| content.replace('B', "BC"),
980            }),
981        ];
982
983        let config = Config::default();
984
985        // First run
986        let mut content1 = "A".to_string();
987        let result1 = coordinator
988            .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
989            .unwrap();
990
991        // Second run on same final content
992        let mut content2 = content1.clone();
993        let result2 = coordinator
994            .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
995            .unwrap();
996
997        // Should be identical (idempotent)
998        assert_eq!(content1, content2);
999        assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
1000        assert!(result1.converged);
1001        assert!(result2.converged);
1002    }
1003
1004    #[test]
1005    fn test_apply_fixes_collapses_double_space_without_inline_override() {
1006        // Control: MD064 actually rewrites this content, so the override test below
1007        // is not vacuous - without an inline override the double space is collapsed.
1008        let mut content = String::from("`<svg>`.  Fortunately\n");
1009        let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1010        FixCoordinator::new()
1011            .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1012            .unwrap();
1013        assert_eq!(
1014            content, "`<svg>`. Fortunately\n",
1015            "MD064 collapses the sentence double space when not overridden"
1016        );
1017    }
1018
1019    #[test]
1020    fn test_per_file_ignores_skipped_even_with_unfiltered_rules() {
1021        // The coordinator is the single engine every fix path funnels through. Handed
1022        // the UNFILTERED rule set plus a file path, it must still skip any rule the
1023        // path excludes via [per-file-ignores] - so no caller can reintroduce #707 by
1024        // forgetting to pre-filter.
1025        let coordinator = FixCoordinator::new();
1026        let rules: Vec<Box<dyn Rule>> = vec![
1027            Box::new(ConditionalFixRule {
1028                name: "MD004",
1029                check_fn: |c| c.contains('*'),
1030                fix_fn: |c| c.replace('*', "-"),
1031            }),
1032            Box::new(ConditionalFixRule {
1033                name: "MD032",
1034                check_fn: |c| c.contains("PARENT"),
1035                fix_fn: |c| c.replace("PARENT", "parent"),
1036            }),
1037        ];
1038
1039        let mut config = Config::default();
1040        config
1041            .per_file_ignores
1042            .insert("slides/**/*.md".to_string(), vec!["MD004".to_string()]);
1043        config.canonicalize_rule_lists();
1044
1045        // Path matches the ignore glob: MD004 must be skipped, MD032 must still apply.
1046        let mut content = "* PARENT".to_string();
1047        let result = coordinator
1048            .apply_fixes_iterative(
1049                &rules,
1050                &[],
1051                &mut content,
1052                &config,
1053                10,
1054                Some(std::path::Path::new("slides/deck.md")),
1055            )
1056            .unwrap();
1057        assert_eq!(
1058            content, "* parent",
1059            "MD032 applied, MD004 (`*` -> `-`) skipped for slides/**"
1060        );
1061        assert!(result.fixed_rule_names.contains("MD032"));
1062        assert!(!result.fixed_rule_names.contains("MD004"));
1063
1064        // Control: a path NOT matching the glob applies both rules, proving the skip
1065        // above is driven by per-file-ignores and not something else.
1066        let mut other = "* PARENT".to_string();
1067        coordinator
1068            .apply_fixes_iterative(
1069                &rules,
1070                &[],
1071                &mut other,
1072                &config,
1073                10,
1074                Some(std::path::Path::new("docs/other.md")),
1075            )
1076            .unwrap();
1077        assert_eq!(
1078            other, "- parent",
1079            "both rules apply when the path is not per-file-ignored"
1080        );
1081    }
1082
1083    #[test]
1084    fn test_apply_fixes_honors_inline_configure_file_overrides() {
1085        // A document that relaxes a rule via an inline `rumdl-configure-file` override
1086        // must survive the fix coordinator unchanged: fixes have to honor inline value
1087        // overrides the same way lint/diagnostics do, otherwise "fix" rewrites content
1088        // the configured rule considers valid.
1089        let mut content = String::from(
1090            "<!-- rumdl-configure-file { \"MD064\": { \"allow-sentence-double-space\": true } } -->\n\n`<svg>`.  Fortunately\n",
1091        );
1092        let original = content.clone();
1093        let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1094        FixCoordinator::new()
1095            .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1096            .unwrap();
1097        assert_eq!(
1098            content, original,
1099            "inline rumdl-configure-file override (allow-sentence-double-space) must prevent the MD064 fix"
1100        );
1101    }
1102}