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/// Rules whose fix lays text out on lines rather than changing what the text says.
12///
13/// Reflow picks wrap points from the width of the inline content in front of it,
14/// and its default mode only revisits a line that is over the limit. A rewrite that
15/// shortens inline content AFTER reflow (a link turned into a shortcut reference, a
16/// bare URL wrapped in angle brackets, spaces removed inside emphasis) therefore
17/// leaves every affected line short for good, and a second run cannot repair it
18/// because a short line is never a finding. Ordering layout after every content
19/// rewrite is the invariant; the sort applies it to the whole rule set, so a rewrite
20/// does not have to be listed here to be covered (listing MD054 by hand is exactly
21/// what was missing when it slipped through).
22const LAYOUT_RULES: &[&str] = &["MD013"];
23
24/// Whether configuration lets `rule_name` apply fixes: `unfixable` does not
25/// list it, and `fixable`, when set, does.
26///
27/// Config rule lists are canonical (`Config::canonicalize_rule_lists`), so a
28/// name compares exactly with `Rule::name()`.
29pub fn config_allows_fix(config: &Config, rule_name: &str) -> bool {
30    let global = &config.global;
31    !global.unfixable.iter().any(|name| name == rule_name)
32        && (global.fixable.is_empty() || global.fixable.iter().any(|name| name == rule_name))
33}
34
35/// Where the fix loop spends its time, printed as `[FIX]` lines when
36/// `RUMDL_PROFILE_RULES` is set, beside the `[RULE]` lines of the lint pass.
37///
38/// The fix loop re-parses the document and re-checks rules after every fix, so a
39/// rule's cost there is its lint cost times the number of times it runs. The
40/// report says how many contexts were built and, per rule, how often it was
41/// checked, how often its `fix()` was called (whether or not it changed
42/// anything), and the time both took together.
43#[cfg(not(target_arch = "wasm32"))]
44struct FixLoopProfile {
45    contexts: std::cell::Cell<usize>,
46    parse: std::cell::Cell<std::time::Duration>,
47    rules: std::cell::RefCell<Vec<RuleFixProfile>>,
48}
49
50#[cfg(not(target_arch = "wasm32"))]
51struct RuleFixProfile {
52    name: &'static str,
53    checks: usize,
54    fix_calls: usize,
55    elapsed: std::time::Duration,
56}
57
58#[cfg(not(target_arch = "wasm32"))]
59impl FixLoopProfile {
60    fn from_env() -> Option<Self> {
61        std::env::var_os("RUMDL_PROFILE_RULES").map(|_| Self {
62            contexts: std::cell::Cell::new(0),
63            parse: std::cell::Cell::new(std::time::Duration::ZERO),
64            rules: std::cell::RefCell::new(Vec::new()),
65        })
66    }
67
68    fn record_context(&self, elapsed: std::time::Duration) {
69        self.contexts.set(self.contexts.get() + 1);
70        self.parse.set(self.parse.get() + elapsed);
71    }
72
73    fn with_rule(&self, name: &'static str, update: impl FnOnce(&mut RuleFixProfile)) {
74        let mut rules = self.rules.borrow_mut();
75        let index = rules.iter().position(|rule| rule.name == name).unwrap_or_else(|| {
76            rules.push(RuleFixProfile {
77                name,
78                checks: 0,
79                fix_calls: 0,
80                elapsed: std::time::Duration::ZERO,
81            });
82            rules.len() - 1
83        });
84        update(&mut rules[index]);
85    }
86
87    /// Count one check of `name` and time it, and any fix that follows, until
88    /// the returned guard drops.
89    fn time<'a>(&'a self, name: &'static str) -> RuleFixTimer<'a> {
90        self.with_rule(name, |rule| rule.checks += 1);
91        RuleFixTimer {
92            profile: self,
93            name,
94            start: std::time::Instant::now(),
95        }
96    }
97
98    fn fix_attempted(&self, name: &'static str) {
99        self.with_rule(name, |rule| rule.fix_calls += 1);
100    }
101}
102
103#[cfg(not(target_arch = "wasm32"))]
104impl Drop for FixLoopProfile {
105    fn drop(&mut self) {
106        eprintln!(
107            "[FIX]  {:6} {:?} ({} contexts)",
108            "parse",
109            self.parse.get(),
110            self.contexts.get()
111        );
112        for rule in self.rules.borrow().iter() {
113            eprintln!(
114                "[FIX]  {:6} {:?} ({} checks, {} fix calls)",
115                rule.name, rule.elapsed, rule.checks, rule.fix_calls
116            );
117        }
118    }
119}
120
121#[cfg(not(target_arch = "wasm32"))]
122struct RuleFixTimer<'a> {
123    profile: &'a FixLoopProfile,
124    name: &'static str,
125    start: std::time::Instant,
126}
127
128#[cfg(not(target_arch = "wasm32"))]
129impl Drop for RuleFixTimer<'_> {
130    fn drop(&mut self) {
131        let elapsed = self.start.elapsed();
132        self.profile.with_rule(self.name, |rule| rule.elapsed += elapsed);
133    }
134}
135
136/// Result of applying fixes iteratively
137///
138/// This struct provides named fields instead of a tuple to prevent
139/// confusion about the meaning of each value.
140#[derive(Debug, Clone)]
141pub struct FixResult {
142    /// Total number of rules that successfully applied fixes
143    pub rules_fixed: usize,
144    /// Number of fix iterations performed
145    pub iterations: usize,
146    /// Number of LintContext instances created during fixing
147    pub context_creations: usize,
148    /// Names of rules that applied fixes
149    pub fixed_rule_names: HashSet<String>,
150    /// Whether the fix process converged (content stabilized)
151    pub converged: bool,
152    /// Rules identified as participants in an oscillation cycle.
153    /// Populated only when `converged == false` and a cycle was detected.
154    /// Empty when the fix loop hit `max_iterations` without cycling.
155    pub conflicting_rules: Vec<String>,
156    /// Ordered rule sequence observed in the cycle.
157    /// If non-empty, this can be rendered as a loop by appending the first rule
158    /// at the end (e.g. `MD044 -> MD063 -> MD044`).
159    pub conflict_cycle: Vec<String>,
160}
161
162/// Calculate hash of content for convergence detection
163fn hash_content(content: &str) -> u64 {
164    let mut hasher = DefaultHasher::new();
165    content.hash(&mut hasher);
166    hasher.finish()
167}
168
169/// Coordinates rule fixing to minimize the number of passes needed
170pub struct FixCoordinator {
171    /// Rules that should run before others (rule -> rules that depend on it)
172    dependencies: HashMap<&'static str, Vec<&'static str>>,
173}
174
175impl Default for FixCoordinator {
176    fn default() -> Self {
177        Self::new()
178    }
179}
180
181impl FixCoordinator {
182    pub fn new() -> Self {
183        let mut dependencies = HashMap::new();
184
185        // CRITICAL DEPENDENCIES:
186        // These dependencies prevent cascading issues that require multiple passes
187
188        // MD064 (multiple consecutive spaces) MUST run before:
189        // - MD010 (tabs->spaces) - MD010 replaces tabs with multiple spaces (e.g., 4),
190        //   which MD064 would incorrectly collapse back to 1 space if it ran after
191        dependencies.insert("MD064", vec!["MD010"]);
192
193        // MD010 (tabs->spaces) MUST run before:
194        // - MD007 (list indentation) - because tabs affect indent calculation
195        // - MD005 (list indent consistency) - same reason
196        dependencies.insert("MD010", vec!["MD007", "MD005"]);
197
198        // MD013 (line length) MUST run before:
199        // - MD009 (trailing spaces) - line wrapping might add trailing spaces that need cleanup
200        // - MD012 (multiple blanks) - reflowing can affect blank lines
201        // Note: MD013 now trims trailing whitespace during reflow to prevent mid-line spaces
202        dependencies.insert("MD013", vec!["MD009", "MD012"]);
203
204        // MD004 (list style) should run before:
205        // - MD007 (list indentation) - changing markers affects indentation
206        dependencies.insert("MD004", vec!["MD007"]);
207
208        // MD022/MD023 (heading spacing) should run before:
209        // - MD012 (multiple blanks) - heading fixes can affect blank lines
210        dependencies.insert("MD022", vec!["MD012"]);
211        dependencies.insert("MD023", vec!["MD012"]);
212
213        // MD070 (nested fence collision) MUST run before:
214        // - MD040 (code language) - MD070 changes block structure, making orphan fences into content
215        // - MD031 (blanks around fences) - same reason
216        dependencies.insert("MD070", vec!["MD040", "MD031"]);
217
218        // MD005/MD077 (list indent and continuation indent) MUST run before:
219        // - MD032 (blanks around lists) - MD005/MD077 fix nesting structure that MD032 relies on
220        //   to correctly identify list block boundaries; running MD032 first on under-indented
221        //   content causes spurious blank-line insertions inside list items
222        dependencies.insert("MD005", vec!["MD032"]);
223        dependencies.insert("MD077", vec!["MD032"]);
224
225        Self { dependencies }
226    }
227
228    /// The given rules plus everything that transitively depends on them.
229    fn downstream_of(&self, roots: &[&'static str]) -> HashSet<&'static str> {
230        let mut closure: HashSet<&'static str> = HashSet::new();
231        let mut pending: Vec<&'static str> = roots.to_vec();
232        while let Some(name) = pending.pop() {
233            if !closure.insert(name) {
234                continue;
235            }
236            if let Some(dependents) = self.dependencies.get(name) {
237                pending.extend(dependents.iter().copied());
238            }
239        }
240        closure
241    }
242
243    /// Get the optimal order for running rules based on dependencies
244    pub fn get_optimal_order<'a>(&self, rules: &'a [Box<dyn Rule>]) -> Vec<&'a dyn Rule> {
245        // Build a map of rule names to rules for quick lookup
246        let rule_map: HashMap<&str, &dyn Rule> = rules.iter().map(|r| (r.name(), r.as_ref())).collect();
247        // Input position of each rule. Prerequisites are visited in this order so
248        // that rules the dependency table leaves unordered keep their input order,
249        // and so the result is the same on every run: the sets below are hashed,
250        // and iterating them directly would let the hasher's per-process seed pick
251        // the order of the whole rule set.
252        let position: HashMap<&str, usize> = rules.iter().enumerate().map(|(i, r)| (r.name(), i)).collect();
253
254        // Build reverse dependencies (rule -> rules it depends on)
255        let mut reverse_deps: HashMap<&str, HashSet<&str>> = HashMap::new();
256        for (prereq, dependents) in &self.dependencies {
257            for dependent in dependents {
258                reverse_deps.entry(dependent).or_default().insert(prereq);
259            }
260        }
261
262        // A layout rule runs after every rule that is not downstream of it. Rules
263        // downstream of a layout rule (its dependents, transitively) are excluded so
264        // the declared edges MD013 -> MD009/MD012 stay acyclic.
265        let downstream_of_layout = self.downstream_of(LAYOUT_RULES);
266        for &layout_rule in LAYOUT_RULES {
267            let prereqs = reverse_deps.entry(layout_rule).or_default();
268            prereqs.extend(
269                rule_map
270                    .keys()
271                    .copied()
272                    .filter(|name| *name != layout_rule && !downstream_of_layout.contains(name)),
273            );
274        }
275
276        // Perform topological sort
277        let mut sorted = Vec::new();
278        let mut visited: HashSet<&str> = HashSet::new();
279        let mut visiting: HashSet<&str> = HashSet::new();
280
281        fn visit<'a, 'b>(
282            rule_name: &'b str,
283            rule_map: &HashMap<&str, &'a dyn Rule>,
284            position: &HashMap<&str, usize>,
285            reverse_deps: &HashMap<&'b str, HashSet<&'b str>>,
286            visited: &mut HashSet<&'b str>,
287            visiting: &mut HashSet<&'b str>,
288            sorted: &mut Vec<&'a dyn Rule>,
289        ) where
290            'a: 'b,
291        {
292            if visited.contains(rule_name) {
293                return;
294            }
295
296            if visiting.contains(rule_name) {
297                // Cycle detected, but we'll just skip it
298                return;
299            }
300
301            visiting.insert(rule_name);
302
303            // Visit dependencies first, in input order
304            if let Some(deps) = reverse_deps.get(rule_name) {
305                let mut deps: Vec<&'b str> = deps.iter().copied().filter(|dep| rule_map.contains_key(dep)).collect();
306                deps.sort_by_key(|dep| position[dep]);
307                for dep in deps {
308                    visit(dep, rule_map, position, reverse_deps, visited, visiting, sorted);
309                }
310            }
311
312            visiting.remove(rule_name);
313            visited.insert(rule_name);
314
315            // Add this rule to sorted list
316            if let Some(&rule) = rule_map.get(rule_name) {
317                sorted.push(rule);
318            }
319        }
320
321        // Visit all rules
322        for rule in rules {
323            visit(
324                rule.name(),
325                &rule_map,
326                &position,
327                &reverse_deps,
328                &mut visited,
329                &mut visiting,
330                &mut sorted,
331            );
332        }
333
334        // Add any rules not in dependency graph
335        for rule in rules {
336            if !sorted.iter().any(|r| r.name() == rule.name()) {
337                sorted.push(rule.as_ref());
338            }
339        }
340
341        sorted
342    }
343
344    /// Apply fixes iteratively until no more fixes are needed or max iterations reached.
345    ///
346    /// This implements a Ruff-inspired fix loop that re-parses the document after each
347    /// fix, so a later rule sees what an earlier one produced (e.g., MD046 creating code
348    /// blocks that MD040 needs to fix). After a fix the loop carries on with the next
349    /// rule in dependency order, then wraps round to the rules before it, and stops once
350    /// one full pass over every rule applies nothing.
351    ///
352    /// The `file_path` parameter is used to determine per-file flavor overrides. If provided,
353    /// the flavor for creating LintContext will be resolved using `config.get_flavor_for_file()`.
354    pub fn apply_fixes_iterative(
355        &self,
356        rules: &[Box<dyn Rule>],
357        all_warnings: &[LintWarning], // Kept for API compatibility, but we re-check all rules
358        content: &mut String,
359        config: &Config,
360        max_iterations: usize,
361        file_path: Option<&std::path::Path>,
362    ) -> Result<FixResult, String> {
363        self.apply_fixes_iterative_with_paths(
364            rules,
365            all_warnings,
366            content,
367            config,
368            max_iterations,
369            crate::DocumentPaths::same(file_path),
370        )
371    }
372
373    /// Apply fixes with separate paths for configuration matching and rule filesystem access.
374    ///
375    /// Native file adapters pass the same path for both. Virtual adapters can use a
376    /// logical `config_path` for per-file flavor and ignores while leaving
377    /// `source_file` unset so filesystem-dependent rules stay disabled.
378    pub fn apply_fixes_iterative_with_paths(
379        &self,
380        rules: &[Box<dyn Rule>],
381        _all_warnings: &[LintWarning],
382        content: &mut String,
383        config: &Config,
384        max_iterations: usize,
385        paths: crate::DocumentPaths<'_>,
386    ) -> Result<FixResult, String> {
387        if crate::merge_conflict::detect_for_rules(content, rules, config, paths.config_path).is_some() {
388            return Ok(FixResult {
389                rules_fixed: 0,
390                iterations: 0,
391                context_creations: 0,
392                fixed_rule_names: HashSet::new(),
393                converged: true,
394                conflicting_rules: Vec::new(),
395                conflict_cycle: Vec::new(),
396            });
397        }
398
399        // Use the minimum of max_iterations parameter and MAX_ITERATIONS constant
400        let max_iterations = max_iterations.min(MAX_ITERATIONS);
401
402        // Get optimal rule order based on dependencies
403        let ordered_rules = self.get_optimal_order(rules);
404
405        let mut total_fixed = 0;
406        let mut total_ctx_creations = 0;
407        let mut iterations = 0;
408
409        // History tracks (content_hash, rule_that_produced_this_state).
410        // The initial entry has an empty rule name (no rule produced the initial content).
411        let mut history: Vec<(u64, &str)> = vec![(hash_content(content), "")];
412
413        // Track which rules actually applied fixes
414        let mut fixed_rule_names: HashSet<&str> = HashSet::new();
415
416        // Per-file-ignores are config-driven, per-file rule exclusions. The
417        // coordinator is the single engine every fix path funnels through, so
418        // resolving them here guarantees `fmt`/fix never rewrites a rule the file
419        // has excluded - no caller can reintroduce issue #707 by forgetting to
420        // pre-filter. Empty when no path is available (e.g. WASM without a path).
421        let ignored_for_file: HashSet<String> = paths
422            .config_path
423            .map(|p| config.get_ignored_rules_for_file(p))
424            .unwrap_or_default();
425
426        // Where the next iteration resumes in `ordered_rules`. Restarting from the
427        // first rule after every fix would re-check the rules before the one that
428        // fixed on each iteration; they get the new content on the wrap instead.
429        let mut resume_at = 0;
430
431        #[cfg(not(target_arch = "wasm32"))]
432        let profile = FixLoopProfile::from_env();
433
434        // Ruff-style fix loop: keep applying fixes until content stabilizes
435        while iterations < max_iterations {
436            iterations += 1;
437
438            // Create fresh context for this iteration
439            // Use per-file flavor if file_path is provided, otherwise fall back to global flavor
440            let flavor = paths
441                .config_path
442                .map_or_else(|| config.markdown_flavor(), |path| config.get_flavor_for_file(path));
443            #[cfg(not(target_arch = "wasm32"))]
444            let parse_start = profile.as_ref().map(|_| std::time::Instant::now());
445            let ctx = crate::time_section!("fix: build context", {
446                LintContext::new(content, flavor, paths.source_file.map(std::path::Path::to_path_buf))
447            });
448            #[cfg(not(target_arch = "wasm32"))]
449            if let (Some(profile), Some(start)) = (profile.as_ref(), parse_start) {
450                profile.record_context(start.elapsed());
451            }
452            total_ctx_creations += 1;
453
454            // Inline `rumdl-configure-file` value overrides: when the document carries
455            // inline rule-config overrides, recreate the affected rules from the merged
456            // config so fixes honor them, matching the lint/diagnostics path. Without
457            // this, fix() would run with the base config and could rewrite content the
458            // configured rule considers valid.
459            let recreated_rules: HashMap<String, Box<dyn Rule>> = {
460                let inline_overrides = ctx.inline_config().get_all_rule_configs();
461                if inline_overrides.is_empty() {
462                    HashMap::new()
463                } else {
464                    let merged = config.merge_with_inline_config(ctx.inline_config());
465                    inline_overrides
466                        .keys()
467                        .filter_map(|name| {
468                            crate::rules::create_rule_by_name(name, &merged).map(|rule| (name.clone(), rule))
469                        })
470                        .collect()
471                }
472            };
473
474            let mut any_fix_applied = false;
475            // The rule that applied a fix this iteration (used for cycle reporting).
476            let mut this_iter_rule: &str = "";
477
478            // Check and fix each rule in dependency order
479            let started_at = resume_at;
480            for (index, rule) in ordered_rules.iter().enumerate().skip(started_at) {
481                if !config_allows_fix(config, rule.name()) {
482                    continue;
483                }
484                // Skip rules excluded for this file via [per-file-ignores].
485                if ignored_for_file.contains(rule.name()) {
486                    continue;
487                }
488
489                // Use the inline-config-recreated instance when present so checks and
490                // fixes reflect inline `rumdl-configure-file` overrides; otherwise the
491                // base rule. Rule identity (name) is unchanged either way.
492                let effective_rule: &dyn Rule = recreated_rules.get(rule.name()).map_or(*rule, |r| r.as_ref());
493
494                // Skip rules that indicate they should be skipped (opt-in rules, content-based skipping)
495                if effective_rule.should_skip(&ctx) {
496                    continue;
497                }
498
499                #[cfg(not(target_arch = "wasm32"))]
500                let _rule_timer = profile.as_ref().map(|profile| profile.time(rule.name()));
501
502                // Check if this rule has any current warnings
503                let Ok(warnings) = crate::time_section!("fix: check rules", { effective_rule.check(&ctx) }) else {
504                    continue;
505                };
506
507                if warnings.is_empty() {
508                    continue;
509                }
510
511                // Filter warnings through inline config to respect disable comments
512                let inline_config = ctx.inline_config();
513                let filtered_warnings =
514                    crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, inline_config, rule.name());
515
516                if filtered_warnings.is_empty() {
517                    continue;
518                }
519
520                // Decide whether to dispatch to rule.fix(). Two paths qualify:
521                //   1. Any non-disabled warning carries an inline Fix (the
522                //      common case — most rules attach per-warning edits).
523                //   2. The rule advertises a fix capability via Rule::fix_capability().
524                //      This is for rules whose fix() rewrites at the document
525                //      level rather than producing per-warning edits (e.g.
526                //      MD046 fence-style normalization).
527                // A rule is skipped only when it has no inline fix AND advertises
528                // no fix capability (Unfixable). Unfixable rules attach no inline
529                // fixes, so in practice they are never dispatched to fix().
530                let has_inline_fix = filtered_warnings.iter().any(|w| w.fix.is_some());
531                let rule_advertises_fix = effective_rule.fix_capability() != FixCapability::Unfixable;
532                if !has_inline_fix && !rule_advertises_fix {
533                    continue;
534                }
535
536                // Apply fix
537                #[cfg(not(target_arch = "wasm32"))]
538                if let Some(profile) = profile.as_ref() {
539                    profile.fix_attempted(rule.name());
540                }
541                match crate::time_section!("fix: apply rule fixes", { effective_rule.fix(&ctx) }) {
542                    Ok(fixed_content) => {
543                        if fixed_content != *content {
544                            *content = fixed_content;
545                            total_fixed += 1;
546                            any_fix_applied = true;
547                            this_iter_rule = rule.name();
548                            fixed_rule_names.insert(rule.name());
549                            // The next rule sees the fixed content through a fresh
550                            // context; after the last rule the loop wraps round.
551                            resume_at = if index + 1 == ordered_rules.len() { 0 } else { index + 1 };
552                            break;
553                        }
554                    }
555                    Err(_) => {
556                        // Error applying fix, continue to next rule
557                        continue;
558                    }
559                }
560            }
561
562            // A clean scan that began part-way through the order has not seen the
563            // rules before it on this content, so it wraps round to them.
564            if !any_fix_applied && started_at > 0 {
565                resume_at = 0;
566                continue;
567            }
568
569            let current_hash = hash_content(content);
570
571            // Check whether this content state has been seen before.
572            if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
573                if cycle_start == history.len() - 1 {
574                    // Content matches the last recorded state: nothing changed this iteration.
575                    return Ok(FixResult {
576                        rules_fixed: total_fixed,
577                        iterations,
578                        context_creations: total_ctx_creations,
579                        fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
580                        converged: true,
581                        conflicting_rules: Vec::new(),
582                        conflict_cycle: Vec::new(),
583                    });
584                } else {
585                    // Content matches an older state: oscillation cycle detected.
586                    // Collect the rules that participate in the cycle.
587                    let conflict_cycle: Vec<String> = history[cycle_start + 1..]
588                        .iter()
589                        .map(|(_, r)| r.to_string())
590                        .chain(std::iter::once(this_iter_rule.to_string()))
591                        .filter(|r| !r.is_empty())
592                        .collect();
593                    let conflicting_rules: Vec<String> = history[cycle_start + 1..]
594                        .iter()
595                        .map(|(_, r)| *r)
596                        .chain(std::iter::once(this_iter_rule))
597                        .filter(|r| !r.is_empty())
598                        .collect::<HashSet<&str>>()
599                        .into_iter()
600                        .map(std::string::ToString::to_string)
601                        .collect();
602                    return Ok(FixResult {
603                        rules_fixed: total_fixed,
604                        iterations,
605                        context_creations: total_ctx_creations,
606                        fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
607                        converged: false,
608                        conflicting_rules,
609                        conflict_cycle,
610                    });
611                }
612            }
613
614            // New state - record it.
615            history.push((current_hash, this_iter_rule));
616
617            // If no fix was applied this iteration, content is stable.
618            if !any_fix_applied {
619                return Ok(FixResult {
620                    rules_fixed: total_fixed,
621                    iterations,
622                    context_creations: total_ctx_creations,
623                    fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
624                    converged: true,
625                    conflicting_rules: Vec::new(),
626                    conflict_cycle: Vec::new(),
627                });
628            }
629        }
630
631        // Hit max iterations without detecting a cycle.
632        Ok(FixResult {
633            rules_fixed: total_fixed,
634            iterations,
635            context_creations: total_ctx_creations,
636            fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
637            converged: false,
638            conflicting_rules: Vec::new(),
639            conflict_cycle: Vec::new(),
640        })
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647    use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
648    use std::sync::atomic::{AtomicUsize, Ordering};
649
650    /// Mock rule that checks content and applies fixes based on a condition
651    #[derive(Clone)]
652    struct ConditionalFixRule {
653        name: &'static str,
654        /// Function to check if content has issues
655        check_fn: fn(&str) -> bool,
656        /// Function to fix content
657        fix_fn: fn(&str) -> String,
658    }
659
660    impl Rule for ConditionalFixRule {
661        fn name(&self) -> &'static str {
662            self.name
663        }
664
665        fn check(&self, ctx: &LintContext) -> LintResult {
666            if (self.check_fn)(ctx.content) {
667                Ok(vec![LintWarning {
668                    line: 1,
669                    column: 1,
670                    end_line: 1,
671                    end_column: 1,
672                    message: format!("{} issue found", self.name),
673                    rule_name: Some(self.name.to_string()),
674                    severity: Severity::Error,
675                    fix: Some(Fix::new(0..0, String::new())),
676                }])
677            } else {
678                Ok(vec![])
679            }
680        }
681
682        fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
683            Ok((self.fix_fn)(ctx.content))
684        }
685
686        fn description(&self) -> &'static str {
687            "Conditional fix rule for testing"
688        }
689
690        fn category(&self) -> RuleCategory {
691            RuleCategory::Whitespace
692        }
693
694        fn as_any(&self) -> &dyn std::any::Any {
695            self
696        }
697    }
698
699    // Simple mock rule for basic tests
700    #[derive(Clone)]
701    struct MockRule {
702        name: &'static str,
703        warnings: Vec<LintWarning>,
704        fix_content: String,
705    }
706
707    impl Rule for MockRule {
708        fn name(&self) -> &'static str {
709            self.name
710        }
711
712        fn check(&self, _ctx: &LintContext) -> LintResult {
713            Ok(self.warnings.clone())
714        }
715
716        fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
717            Ok(self.fix_content.clone())
718        }
719
720        fn description(&self) -> &'static str {
721            "Mock rule for testing"
722        }
723
724        fn category(&self) -> RuleCategory {
725            RuleCategory::Whitespace
726        }
727
728        fn as_any(&self) -> &dyn std::any::Any {
729            self
730        }
731    }
732
733    #[test]
734    fn test_dependency_ordering() {
735        let coordinator = FixCoordinator::new();
736
737        let rules: Vec<Box<dyn Rule>> = vec![
738            Box::new(MockRule {
739                name: "MD009",
740                warnings: vec![],
741                fix_content: "".to_string(),
742            }),
743            Box::new(MockRule {
744                name: "MD013",
745                warnings: vec![],
746                fix_content: "".to_string(),
747            }),
748            Box::new(MockRule {
749                name: "MD010",
750                warnings: vec![],
751                fix_content: "".to_string(),
752            }),
753            Box::new(MockRule {
754                name: "MD007",
755                warnings: vec![],
756                fix_content: "".to_string(),
757            }),
758        ];
759
760        let ordered = coordinator.get_optimal_order(&rules);
761        let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
762
763        // MD010 should come before MD007 (dependency)
764        let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
765        let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
766        assert!(md010_idx < md007_idx, "MD010 should come before MD007");
767
768        // MD013 should come before MD009 (dependency)
769        let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
770        let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
771        assert!(md013_idx < md009_idx, "MD013 should come before MD009");
772    }
773
774    #[test]
775    fn test_single_rule_fix() {
776        let coordinator = FixCoordinator::new();
777
778        // Rule that removes "BAD" from content
779        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
780            name: "RemoveBad",
781            check_fn: |content| content.contains("BAD"),
782            fix_fn: |content| content.replace("BAD", "GOOD"),
783        })];
784
785        let mut content = "This is BAD content".to_string();
786        let config = Config::default();
787
788        let result = coordinator
789            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
790            .unwrap();
791
792        assert_eq!(content, "This is GOOD content");
793        assert_eq!(result.rules_fixed, 1);
794        assert!(result.converged);
795    }
796
797    #[test]
798    fn test_cascading_fixes() {
799        // Simulates MD046 -> MD040 cascade:
800        // Rule1: converts "INDENT" to "FENCE" (like MD046 converting indented to fenced)
801        // Rule2: converts "FENCE" to "FENCE_LANG" (like MD040 adding language)
802        let coordinator = FixCoordinator::new();
803
804        let rules: Vec<Box<dyn Rule>> = vec![
805            Box::new(ConditionalFixRule {
806                name: "Rule1_IndentToFence",
807                check_fn: |content| content.contains("INDENT"),
808                fix_fn: |content| content.replace("INDENT", "FENCE"),
809            }),
810            Box::new(ConditionalFixRule {
811                name: "Rule2_FenceToLang",
812                check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
813                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
814            }),
815        ];
816
817        let mut content = "Code: INDENT".to_string();
818        let config = Config::default();
819
820        let result = coordinator
821            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
822            .unwrap();
823
824        // Should reach final state in one run (internally multiple iterations)
825        assert_eq!(content, "Code: FENCE_LANG");
826        assert_eq!(result.rules_fixed, 2);
827        assert!(result.converged);
828        assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
829    }
830
831    #[test]
832    fn a_rule_that_never_fires_is_checked_once_per_pass_not_once_per_fix() {
833        static QUIET_CHECKS: AtomicUsize = AtomicUsize::new(0);
834        let coordinator = FixCoordinator::new();
835
836        // One rule with nothing to fix, then five that each fix once.
837        let mut rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
838            name: "Quiet",
839            check_fn: |_| {
840                QUIET_CHECKS.fetch_add(1, Ordering::SeqCst);
841                false
842            },
843            fix_fn: str::to_string,
844        })];
845        let fixers = [
846            ConditionalFixRule {
847                name: "FixA",
848                check_fn: |c| c.contains('a'),
849                fix_fn: |c| c.replace('a', "A"),
850            },
851            ConditionalFixRule {
852                name: "FixB",
853                check_fn: |c| c.contains('b'),
854                fix_fn: |c| c.replace('b', "B"),
855            },
856            ConditionalFixRule {
857                name: "FixC",
858                check_fn: |c| c.contains('c'),
859                fix_fn: |c| c.replace('c', "C"),
860            },
861            ConditionalFixRule {
862                name: "FixD",
863                check_fn: |c| c.contains('d'),
864                fix_fn: |c| c.replace('d', "D"),
865            },
866            ConditionalFixRule {
867                name: "FixE",
868                check_fn: |c| c.contains('e'),
869                fix_fn: |c| c.replace('e', "E"),
870            },
871        ];
872        rules.extend(fixers.map(|rule| Box::new(rule) as Box<dyn Rule>));
873
874        let mut content = "a b c d e".to_string();
875        let result = coordinator
876            .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 100, None)
877            .unwrap();
878
879        assert_eq!(content, "A B C D E");
880        assert!(result.converged);
881        assert_eq!(result.rules_fixed, 5);
882        // The pass that applied the five fixes, then the clean pass that proves
883        // nothing is left. Restarting after each fix would check it six times.
884        assert_eq!(QUIET_CHECKS.load(Ordering::SeqCst), 2);
885    }
886
887    #[test]
888    fn a_fix_that_creates_work_for_an_earlier_rule_is_picked_up_on_the_wrap() {
889        let coordinator = FixCoordinator::new();
890
891        // The second rule's fix produces what the first rule fixes, and the
892        // third has nothing to do, so the pass after the fix ends clean without
893        // having looked at the first rule again.
894        let rules: Vec<Box<dyn Rule>> = vec![
895            Box::new(ConditionalFixRule {
896                name: "Early_YToZ",
897                check_fn: |content| content.contains('y'),
898                fix_fn: |content| content.replace('y', "z"),
899            }),
900            Box::new(ConditionalFixRule {
901                name: "Late_XToY",
902                check_fn: |content| content.contains('x'),
903                fix_fn: |content| content.replace('x', "y"),
904            }),
905            Box::new(ConditionalFixRule {
906                name: "Tail_Quiet",
907                check_fn: |_| false,
908                fix_fn: str::to_string,
909            }),
910        ];
911
912        let mut content = "x".to_string();
913        let result = coordinator
914            .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 100, None)
915            .unwrap();
916
917        assert_eq!(content, "z");
918        assert!(result.converged);
919        assert_eq!(result.rules_fixed, 2);
920    }
921
922    #[test]
923    fn test_indirect_cascade() {
924        // Simulates MD022 -> MD046 -> MD040 indirect cascade:
925        // Rule1: adds "BLANK" (like MD022 adding blank line)
926        // Rule2: only triggers if "BLANK" present, converts "CODE" to "FENCE"
927        // Rule3: converts "FENCE" to "FENCE_LANG"
928        let coordinator = FixCoordinator::new();
929
930        let rules: Vec<Box<dyn Rule>> = vec![
931            Box::new(ConditionalFixRule {
932                name: "Rule1_AddBlank",
933                check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
934                fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
935            }),
936            Box::new(ConditionalFixRule {
937                name: "Rule2_CodeToFence",
938                // Only detects CODE as issue if BLANK is present (simulates CommonMark rule)
939                check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
940                fix_fn: |content| content.replace("CODE", "FENCE"),
941            }),
942            Box::new(ConditionalFixRule {
943                name: "Rule3_AddLang",
944                check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
945                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
946            }),
947        ];
948
949        let mut content = "HEADING CODE".to_string();
950        let config = Config::default();
951
952        let result = coordinator
953            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
954            .unwrap();
955
956        // Key assertion: all fixes applied in single run
957        assert_eq!(content, "HEADING BLANK FENCE_LANG");
958        assert_eq!(result.rules_fixed, 3);
959        assert!(result.converged);
960    }
961
962    #[test]
963    fn test_unfixable_rules_skipped() {
964        let coordinator = FixCoordinator::new();
965
966        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
967            name: "MD001",
968            check_fn: |content| content.contains("BAD"),
969            fix_fn: |content| content.replace("BAD", "GOOD"),
970        })];
971
972        let mut content = "BAD content".to_string();
973        let mut config = Config::default();
974        config.global.unfixable = vec!["MD001".to_string()];
975
976        let result = coordinator
977            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
978            .unwrap();
979
980        assert_eq!(content, "BAD content"); // Should not be changed
981        assert_eq!(result.rules_fixed, 0);
982        assert!(result.converged);
983    }
984
985    #[test]
986    fn test_fixable_allowlist() {
987        let coordinator = FixCoordinator::new();
988
989        let rules: Vec<Box<dyn Rule>> = vec![
990            Box::new(ConditionalFixRule {
991                name: "MD001",
992                check_fn: |content| content.contains('A'),
993                fix_fn: |content| content.replace('A', "X"),
994            }),
995            Box::new(ConditionalFixRule {
996                name: "MD002",
997                check_fn: |content| content.contains('B'),
998                fix_fn: |content| content.replace('B', "Y"),
999            }),
1000        ];
1001
1002        let mut content = "AB".to_string();
1003        let mut config = Config::default();
1004        config.global.fixable = vec!["MD001".to_string()];
1005
1006        let result = coordinator
1007            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1008            .unwrap();
1009
1010        assert_eq!(content, "XB"); // Only A->X, B unchanged
1011        assert_eq!(result.rules_fixed, 1);
1012    }
1013
1014    /// Aliases in `unfixable` (e.g. `"heading-increment"`) must reach
1015    /// `apply_fixes_iterative` already canonicalised — the runtime invariant
1016    /// enforced by `Config::canonicalize_rule_lists` at every mutation
1017    /// boundary (`From<SourcedConfig> for Config`, LSP `apply_lsp_settings_*`,
1018    /// WASM `to_config_with_warnings`). The fix coordinator therefore matches
1019    /// against `Rule::name()` with plain string equality.
1020    #[test]
1021    fn test_unfixable_rules_resolved_from_alias() {
1022        let coordinator = FixCoordinator::new();
1023
1024        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
1025            name: "MD001",
1026            check_fn: |content| content.contains("BAD"),
1027            fix_fn: |content| content.replace("BAD", "GOOD"),
1028        })];
1029
1030        let mut content = "BAD content".to_string();
1031        let mut config = Config::default();
1032        // Caller writes the alias…
1033        config.global.unfixable = vec!["heading-increment".to_string()];
1034        // …and the boundary canonicalises it to "MD001" before lint/fix sees it.
1035        config.canonicalize_rule_lists();
1036
1037        let result = coordinator
1038            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1039            .unwrap();
1040
1041        assert_eq!(content, "BAD content");
1042        assert_eq!(result.rules_fixed, 0);
1043        assert!(result.converged);
1044    }
1045
1046    /// Counterpart to `test_unfixable_rules_resolved_from_alias` for the
1047    /// fixable allowlist. Same invariant: callers may write aliases, but the
1048    /// boundary canonicalises before the fix coordinator sees the config.
1049    #[test]
1050    fn test_fixable_allowlist_resolved_from_alias() {
1051        let coordinator = FixCoordinator::new();
1052
1053        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
1054            name: "MD001",
1055            check_fn: |content| content.contains("BAD"),
1056            fix_fn: |content| content.replace("BAD", "GOOD"),
1057        })];
1058
1059        let mut content = "BAD content".to_string();
1060        let mut config = Config::default();
1061        config.global.fixable = vec!["heading-increment".to_string()];
1062        config.canonicalize_rule_lists();
1063
1064        let result = coordinator
1065            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1066            .unwrap();
1067
1068        assert_eq!(content, "GOOD content");
1069        assert_eq!(result.rules_fixed, 1);
1070    }
1071
1072    #[test]
1073    fn test_max_iterations_limit() {
1074        let coordinator = FixCoordinator::new();
1075
1076        // Rule that always changes content (pathological case)
1077        static COUNTER: AtomicUsize = AtomicUsize::new(0);
1078
1079        #[derive(Clone)]
1080        struct AlwaysChangeRule;
1081        impl Rule for AlwaysChangeRule {
1082            fn name(&self) -> &'static str {
1083                "AlwaysChange"
1084            }
1085            fn check(&self, _: &LintContext) -> LintResult {
1086                Ok(vec![LintWarning {
1087                    line: 1,
1088                    column: 1,
1089                    end_line: 1,
1090                    end_column: 1,
1091                    message: "Always".to_string(),
1092                    rule_name: Some("AlwaysChange".to_string()),
1093                    severity: Severity::Error,
1094                    fix: Some(Fix::new(0..0, String::new())),
1095                }])
1096            }
1097            fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
1098                COUNTER.fetch_add(1, Ordering::SeqCst);
1099                Ok(format!("{}x", ctx.content))
1100            }
1101            fn description(&self) -> &'static str {
1102                "Always changes"
1103            }
1104            fn category(&self) -> RuleCategory {
1105                RuleCategory::Whitespace
1106            }
1107            fn as_any(&self) -> &dyn std::any::Any {
1108                self
1109            }
1110        }
1111
1112        COUNTER.store(0, Ordering::SeqCst);
1113        let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
1114
1115        let mut content = "test".to_string();
1116        let config = Config::default();
1117
1118        let result = coordinator
1119            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1120            .unwrap();
1121
1122        // Should stop at max iterations
1123        assert_eq!(result.iterations, 5);
1124        assert!(!result.converged);
1125        assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
1126    }
1127
1128    #[test]
1129    fn test_empty_rules() {
1130        let coordinator = FixCoordinator::new();
1131        let rules: Vec<Box<dyn Rule>> = vec![];
1132
1133        let mut content = "unchanged".to_string();
1134        let config = Config::default();
1135
1136        let result = coordinator
1137            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1138            .unwrap();
1139
1140        assert_eq!(result.rules_fixed, 0);
1141        assert_eq!(result.iterations, 1);
1142        assert!(result.converged);
1143        assert_eq!(content, "unchanged");
1144    }
1145
1146    #[test]
1147    fn test_no_warnings_no_changes() {
1148        let coordinator = FixCoordinator::new();
1149
1150        // Rule that finds no issues
1151        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
1152            name: "NoIssues",
1153            check_fn: |_| false, // Never finds issues
1154            fix_fn: |content| content.to_string(),
1155        })];
1156
1157        let mut content = "clean content".to_string();
1158        let config = Config::default();
1159
1160        let result = coordinator
1161            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1162            .unwrap();
1163
1164        assert_eq!(content, "clean content");
1165        assert_eq!(result.rules_fixed, 0);
1166        assert!(result.converged);
1167    }
1168
1169    #[test]
1170    fn test_oscillation_detection() {
1171        // Two rules that fight each other: Rule A changes "foo" → "bar", Rule B changes "bar" → "foo".
1172        // The fix loop should detect this as an oscillation cycle and stop early with
1173        // conflicting_rules populated rather than running all 100 iterations.
1174        let coordinator = FixCoordinator::new();
1175
1176        let rules: Vec<Box<dyn Rule>> = vec![
1177            Box::new(ConditionalFixRule {
1178                name: "RuleA",
1179                check_fn: |content| content.contains("foo"),
1180                fix_fn: |content| content.replace("foo", "bar"),
1181            }),
1182            Box::new(ConditionalFixRule {
1183                name: "RuleB",
1184                check_fn: |content| content.contains("bar"),
1185                fix_fn: |content| content.replace("bar", "foo"),
1186            }),
1187        ];
1188
1189        let mut content = "foo".to_string();
1190        let config = Config::default();
1191
1192        let result = coordinator
1193            .apply_fixes_iterative(&rules, &[], &mut content, &config, 100, None)
1194            .unwrap();
1195
1196        // Should detect the cycle quickly, not burn through all 100 iterations.
1197        assert!(!result.converged, "Should not converge in an oscillating pair");
1198        assert!(
1199            result.iterations < 10,
1200            "Cycle detection should stop well before max_iterations (got {})",
1201            result.iterations
1202        );
1203
1204        // Both conflicting rules should be identified.
1205        let mut conflicting = result.conflicting_rules.clone();
1206        conflicting.sort();
1207        assert_eq!(
1208            conflicting,
1209            vec!["RuleA".to_string(), "RuleB".to_string()],
1210            "Both oscillating rules must be reported"
1211        );
1212        assert_eq!(
1213            result.conflict_cycle,
1214            vec!["RuleA".to_string(), "RuleB".to_string()],
1215            "Cycle should preserve the observed application order"
1216        );
1217    }
1218
1219    #[test]
1220    fn test_cyclic_dependencies_handled() {
1221        let mut coordinator = FixCoordinator::new();
1222
1223        // Create a cycle: A -> B -> C -> A
1224        coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
1225        coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
1226        coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
1227
1228        let rules: Vec<Box<dyn Rule>> = vec![
1229            Box::new(MockRule {
1230                name: "RuleA",
1231                warnings: vec![],
1232                fix_content: "".to_string(),
1233            }),
1234            Box::new(MockRule {
1235                name: "RuleB",
1236                warnings: vec![],
1237                fix_content: "".to_string(),
1238            }),
1239            Box::new(MockRule {
1240                name: "RuleC",
1241                warnings: vec![],
1242                fix_content: "".to_string(),
1243            }),
1244        ];
1245
1246        // Should not panic or infinite loop
1247        let ordered = coordinator.get_optimal_order(&rules);
1248
1249        // Should return all rules despite cycle
1250        assert_eq!(ordered.len(), 3);
1251    }
1252
1253    #[test]
1254    fn test_fix_is_idempotent() {
1255        // This is the key test for issue #271
1256        let coordinator = FixCoordinator::new();
1257
1258        let rules: Vec<Box<dyn Rule>> = vec![
1259            Box::new(ConditionalFixRule {
1260                name: "Rule1",
1261                check_fn: |content| content.contains('A'),
1262                fix_fn: |content| content.replace('A', "B"),
1263            }),
1264            Box::new(ConditionalFixRule {
1265                name: "Rule2",
1266                check_fn: |content| content.contains('B') && !content.contains('C'),
1267                fix_fn: |content| content.replace('B', "BC"),
1268            }),
1269        ];
1270
1271        let config = Config::default();
1272
1273        // First run
1274        let mut content1 = "A".to_string();
1275        let result1 = coordinator
1276            .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
1277            .unwrap();
1278
1279        // Second run on same final content
1280        let mut content2 = content1.clone();
1281        let result2 = coordinator
1282            .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
1283            .unwrap();
1284
1285        // Should be identical (idempotent)
1286        assert_eq!(content1, content2);
1287        assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
1288        assert!(result1.converged);
1289        assert!(result2.converged);
1290    }
1291
1292    #[test]
1293    fn test_apply_fixes_collapses_double_space_without_inline_override() {
1294        // Control: MD064 actually rewrites this content, so the override test below
1295        // is not vacuous - without an inline override the double space is collapsed.
1296        let mut content = String::from("`<svg>`.  Fortunately\n");
1297        let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1298        FixCoordinator::new()
1299            .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1300            .unwrap();
1301        assert_eq!(
1302            content, "`<svg>`. Fortunately\n",
1303            "MD064 collapses the sentence double space when not overridden"
1304        );
1305    }
1306
1307    #[test]
1308    fn test_per_file_ignores_skipped_even_with_unfiltered_rules() {
1309        // The coordinator is the single engine every fix path funnels through. Handed
1310        // the UNFILTERED rule set plus a file path, it must still skip any rule the
1311        // path excludes via [per-file-ignores] - so no caller can reintroduce #707 by
1312        // forgetting to pre-filter.
1313        let coordinator = FixCoordinator::new();
1314        let rules: Vec<Box<dyn Rule>> = vec![
1315            Box::new(ConditionalFixRule {
1316                name: "MD004",
1317                check_fn: |c| c.contains('*'),
1318                fix_fn: |c| c.replace('*', "-"),
1319            }),
1320            Box::new(ConditionalFixRule {
1321                name: "MD032",
1322                check_fn: |c| c.contains("PARENT"),
1323                fix_fn: |c| c.replace("PARENT", "parent"),
1324            }),
1325        ];
1326
1327        let mut config = Config::default();
1328        config
1329            .per_file_ignores
1330            .insert("slides/**/*.md".to_string(), vec!["MD004".to_string()]);
1331        config.canonicalize_rule_lists();
1332
1333        // Path matches the ignore glob: MD004 must be skipped, MD032 must still apply.
1334        let mut content = "* PARENT".to_string();
1335        let result = coordinator
1336            .apply_fixes_iterative(
1337                &rules,
1338                &[],
1339                &mut content,
1340                &config,
1341                10,
1342                Some(std::path::Path::new("slides/deck.md")),
1343            )
1344            .unwrap();
1345        assert_eq!(
1346            content, "* parent",
1347            "MD032 applied, MD004 (`*` -> `-`) skipped for slides/**"
1348        );
1349        assert!(result.fixed_rule_names.contains("MD032"));
1350        assert!(!result.fixed_rule_names.contains("MD004"));
1351
1352        // Control: a path NOT matching the glob applies both rules, proving the skip
1353        // above is driven by per-file-ignores and not something else.
1354        let mut other = "* PARENT".to_string();
1355        coordinator
1356            .apply_fixes_iterative(
1357                &rules,
1358                &[],
1359                &mut other,
1360                &config,
1361                10,
1362                Some(std::path::Path::new("docs/other.md")),
1363            )
1364            .unwrap();
1365        assert_eq!(
1366            other, "- parent",
1367            "both rules apply when the path is not per-file-ignored"
1368        );
1369    }
1370
1371    #[test]
1372    fn test_apply_fixes_honors_inline_configure_file_overrides() {
1373        // A document that relaxes a rule via an inline `rumdl-configure-file` override
1374        // must survive the fix coordinator unchanged: fixes have to honor inline value
1375        // overrides the same way lint/diagnostics do, otherwise "fix" rewrites content
1376        // the configured rule considers valid.
1377        let mut content = String::from(
1378            "<!-- rumdl-configure-file { \"MD064\": { \"allow-sentence-double-space\": true } } -->\n\n`<svg>`.  Fortunately\n",
1379        );
1380        let original = content.clone();
1381        let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1382        FixCoordinator::new()
1383            .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1384            .unwrap();
1385        assert_eq!(
1386            content, original,
1387            "inline rumdl-configure-file override (allow-sentence-double-space) must prevent the MD064 fix"
1388        );
1389    }
1390
1391    #[test]
1392    fn layout_rules_run_after_every_content_rewrite() {
1393        // Reflow decides wrap points from the inline content it sees, and its default
1394        // mode never revisits a line that is under the limit. Every rule that is not
1395        // downstream of MD013 must therefore be ordered before it, over the FULL rule
1396        // set, so a new inline rewrite (like MD054 in issue #819) is covered without
1397        // anyone remembering to list it.
1398        let rules = crate::rules::all_rules(&Config::default());
1399        let ordered = FixCoordinator::new().get_optimal_order(&rules);
1400        let names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
1401        assert_eq!(names.len(), rules.len(), "ordering must keep every rule exactly once");
1402
1403        let md013 = names.iter().position(|&n| n == "MD013").unwrap();
1404        let after: Vec<&str> = names[md013 + 1..].to_vec();
1405        let mut expected_after = vec!["MD009", "MD012"];
1406        expected_after.sort_unstable();
1407        let mut actual_after = after.clone();
1408        actual_after.sort_unstable();
1409        assert_eq!(
1410            actual_after, expected_after,
1411            "only MD013's own dependents may follow it; order was {names:?}"
1412        );
1413    }
1414
1415    #[test]
1416    fn link_style_rewrite_runs_before_reflow_regardless_of_input_order() {
1417        // Regression for issue #819: with MD054 after MD013, reflow wrapped on the
1418        // `[text](url)` width and the shortcut rewrite then left every line short.
1419        let coordinator = FixCoordinator::new();
1420        for input in [vec!["MD013", "MD054"], vec!["MD054", "MD013"]] {
1421            let rules: Vec<Box<dyn Rule>> = input
1422                .iter()
1423                .map(|name| crate::rules::create_rule_by_name(name, &Config::default()).unwrap())
1424                .collect();
1425            let names: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
1426            assert_eq!(names, vec!["MD054", "MD013"], "input order {input:?}");
1427        }
1428    }
1429
1430    #[test]
1431    fn optimal_order_is_the_same_on_every_call() {
1432        // The prerequisite sets are hashed, and every HashMap gets its own seed, so
1433        // an ordering that iterated them directly would differ from one call (and
1434        // one process) to the next. That showed up as `rumdl fmt` producing two
1435        // different outputs for the same file. Rules the table leaves unordered
1436        // keep their input order instead.
1437        let rules = crate::rules::all_rules(&Config::default());
1438        let coordinator = FixCoordinator::new();
1439        let first: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
1440        for _ in 0..20 {
1441            let again: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
1442            assert_eq!(again, first);
1443        }
1444    }
1445}