Skip to main content

rumdl_lib/
fix_coordinator.rs

1use crate::config::Config;
2use crate::lint_context::LintContext;
3use crate::rule::{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        Self { dependencies }
94    }
95
96    /// Get the optimal order for running rules based on dependencies
97    pub fn get_optimal_order<'a>(&self, rules: &'a [Box<dyn Rule>]) -> Vec<&'a dyn Rule> {
98        // Build a map of rule names to rules for quick lookup
99        let rule_map: HashMap<&str, &dyn Rule> = rules.iter().map(|r| (r.name(), r.as_ref())).collect();
100
101        // Build reverse dependencies (rule -> rules it depends on)
102        let mut reverse_deps: HashMap<&str, HashSet<&str>> = HashMap::new();
103        for (prereq, dependents) in &self.dependencies {
104            for dependent in dependents {
105                reverse_deps.entry(dependent).or_default().insert(prereq);
106            }
107        }
108
109        // Perform topological sort
110        let mut sorted = Vec::new();
111        let mut visited: HashSet<&str> = HashSet::new();
112        let mut visiting: HashSet<&str> = HashSet::new();
113
114        fn visit<'a, 'b>(
115            rule_name: &'b str,
116            rule_map: &HashMap<&str, &'a dyn Rule>,
117            reverse_deps: &HashMap<&'b str, HashSet<&'b str>>,
118            visited: &mut HashSet<&'b str>,
119            visiting: &mut HashSet<&'b str>,
120            sorted: &mut Vec<&'a dyn Rule>,
121        ) where
122            'a: 'b,
123        {
124            if visited.contains(rule_name) {
125                return;
126            }
127
128            if visiting.contains(rule_name) {
129                // Cycle detected, but we'll just skip it
130                return;
131            }
132
133            visiting.insert(rule_name);
134
135            // Visit dependencies first
136            if let Some(deps) = reverse_deps.get(rule_name) {
137                for dep in deps {
138                    if rule_map.contains_key(dep) {
139                        visit(dep, rule_map, reverse_deps, visited, visiting, sorted);
140                    }
141                }
142            }
143
144            visiting.remove(rule_name);
145            visited.insert(rule_name);
146
147            // Add this rule to sorted list
148            if let Some(&rule) = rule_map.get(rule_name) {
149                sorted.push(rule);
150            }
151        }
152
153        // Visit all rules
154        for rule in rules {
155            visit(
156                rule.name(),
157                &rule_map,
158                &reverse_deps,
159                &mut visited,
160                &mut visiting,
161                &mut sorted,
162            );
163        }
164
165        // Add any rules not in dependency graph
166        for rule in rules {
167            if !sorted.iter().any(|r| r.name() == rule.name()) {
168                sorted.push(rule.as_ref());
169            }
170        }
171
172        sorted
173    }
174
175    /// Apply fixes iteratively until no more fixes are needed or max iterations reached.
176    ///
177    /// This implements a Ruff-inspired fix loop that re-checks ALL rules after each fix
178    /// to detect cascading issues (e.g., MD046 creating code blocks that MD040 needs to fix).
179    ///
180    /// The `file_path` parameter is used to determine per-file flavor overrides. If provided,
181    /// the flavor for creating LintContext will be resolved using `config.get_flavor_for_file()`.
182    pub fn apply_fixes_iterative(
183        &self,
184        rules: &[Box<dyn Rule>],
185        _all_warnings: &[LintWarning], // Kept for API compatibility, but we re-check all rules
186        content: &mut String,
187        config: &Config,
188        max_iterations: usize,
189        file_path: Option<&std::path::Path>,
190    ) -> Result<FixResult, String> {
191        // Use the minimum of max_iterations parameter and MAX_ITERATIONS constant
192        let max_iterations = max_iterations.min(MAX_ITERATIONS);
193
194        // Get optimal rule order based on dependencies
195        let ordered_rules = self.get_optimal_order(rules);
196
197        let mut total_fixed = 0;
198        let mut total_ctx_creations = 0;
199        let mut iterations = 0;
200
201        // History tracks (content_hash, rule_that_produced_this_state).
202        // The initial entry has an empty rule name (no rule produced the initial content).
203        let mut history: Vec<(u64, &str)> = vec![(hash_content(content), "")];
204
205        // Track which rules actually applied fixes
206        let mut fixed_rule_names: HashSet<&str> = HashSet::new();
207
208        // Build set of unfixable rules for quick lookup, resolving aliases to canonical IDs
209        let unfixable_rules: HashSet<String> = config
210            .global
211            .unfixable
212            .iter()
213            .map(|s| crate::config::resolve_rule_name(s))
214            .collect();
215
216        // Build set of fixable rules (if specified), resolving aliases to canonical IDs
217        let fixable_rules: HashSet<String> = config
218            .global
219            .fixable
220            .iter()
221            .map(|s| crate::config::resolve_rule_name(s))
222            .collect();
223        let has_fixable_allowlist = !fixable_rules.is_empty();
224
225        // Ruff-style fix loop: keep applying fixes until content stabilizes
226        while iterations < max_iterations {
227            iterations += 1;
228
229            // Create fresh context for this iteration
230            // Use per-file flavor if file_path is provided, otherwise fall back to global flavor
231            let flavor = file_path
232                .map(|p| config.get_flavor_for_file(p))
233                .unwrap_or_else(|| config.markdown_flavor());
234            let ctx = LintContext::new(content, flavor, file_path.map(|p| p.to_path_buf()));
235            total_ctx_creations += 1;
236
237            let mut any_fix_applied = false;
238            // The rule that applied a fix this iteration (used for cycle reporting).
239            let mut this_iter_rule: &str = "";
240
241            // Check and fix each rule in dependency order
242            for rule in &ordered_rules {
243                // Skip disabled rules
244                if unfixable_rules.contains(rule.name()) {
245                    continue;
246                }
247                if has_fixable_allowlist && !fixable_rules.contains(rule.name()) {
248                    continue;
249                }
250
251                // Skip rules that indicate they should be skipped (opt-in rules, content-based skipping)
252                if rule.should_skip(&ctx) {
253                    continue;
254                }
255
256                // Check if this rule has any current warnings
257                let warnings = match rule.check(&ctx) {
258                    Ok(w) => w,
259                    Err(_) => continue,
260                };
261
262                if warnings.is_empty() {
263                    continue;
264                }
265
266                // Filter warnings through inline config to respect disable comments
267                let inline_config = ctx.inline_config();
268                let filtered_warnings =
269                    crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, inline_config, rule.name());
270
271                if filtered_warnings.is_empty() {
272                    continue;
273                }
274
275                // Check if any non-disabled warnings are fixable
276                let has_fixable = filtered_warnings.iter().any(|w| w.fix.is_some());
277                if !has_fixable {
278                    continue;
279                }
280
281                // Apply fix
282                match rule.fix(&ctx) {
283                    Ok(fixed_content) => {
284                        if fixed_content != *content {
285                            *content = fixed_content;
286                            total_fixed += 1;
287                            any_fix_applied = true;
288                            this_iter_rule = rule.name();
289                            fixed_rule_names.insert(rule.name());
290
291                            // Break to re-check all rules with the new content
292                            // This is the key difference from the old approach:
293                            // we always restart from the beginning after a fix
294                            break;
295                        }
296                    }
297                    Err(_) => {
298                        // Error applying fix, continue to next rule
299                        continue;
300                    }
301                }
302            }
303
304            let current_hash = hash_content(content);
305
306            // Check whether this content state has been seen before.
307            if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
308                if cycle_start == history.len() - 1 {
309                    // Content matches the last recorded state: nothing changed this iteration.
310                    return Ok(FixResult {
311                        rules_fixed: total_fixed,
312                        iterations,
313                        context_creations: total_ctx_creations,
314                        fixed_rule_names: fixed_rule_names.iter().map(|s| s.to_string()).collect(),
315                        converged: true,
316                        conflicting_rules: Vec::new(),
317                        conflict_cycle: Vec::new(),
318                    });
319                } else {
320                    // Content matches an older state: oscillation cycle detected.
321                    // Collect the rules that participate in the cycle.
322                    let conflict_cycle: Vec<String> = history[cycle_start + 1..]
323                        .iter()
324                        .map(|(_, r)| r.to_string())
325                        .chain(std::iter::once(this_iter_rule.to_string()))
326                        .filter(|r| !r.is_empty())
327                        .collect();
328                    let conflicting_rules: Vec<String> = history[cycle_start + 1..]
329                        .iter()
330                        .map(|(_, r)| *r)
331                        .chain(std::iter::once(this_iter_rule))
332                        .filter(|r| !r.is_empty())
333                        .collect::<HashSet<&str>>()
334                        .into_iter()
335                        .map(|s| s.to_string())
336                        .collect();
337                    return Ok(FixResult {
338                        rules_fixed: total_fixed,
339                        iterations,
340                        context_creations: total_ctx_creations,
341                        fixed_rule_names: fixed_rule_names.iter().map(|s| s.to_string()).collect(),
342                        converged: false,
343                        conflicting_rules,
344                        conflict_cycle,
345                    });
346                }
347            }
348
349            // New state - record it.
350            history.push((current_hash, this_iter_rule));
351
352            // If no fix was applied this iteration, content is stable.
353            if !any_fix_applied {
354                return Ok(FixResult {
355                    rules_fixed: total_fixed,
356                    iterations,
357                    context_creations: total_ctx_creations,
358                    fixed_rule_names: fixed_rule_names.iter().map(|s| s.to_string()).collect(),
359                    converged: true,
360                    conflicting_rules: Vec::new(),
361                    conflict_cycle: Vec::new(),
362                });
363            }
364        }
365
366        // Hit max iterations without detecting a cycle.
367        Ok(FixResult {
368            rules_fixed: total_fixed,
369            iterations,
370            context_creations: total_ctx_creations,
371            fixed_rule_names: fixed_rule_names.iter().map(|s| s.to_string()).collect(),
372            converged: false,
373            conflicting_rules: Vec::new(),
374            conflict_cycle: Vec::new(),
375        })
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
383    use std::sync::atomic::{AtomicUsize, Ordering};
384
385    /// Mock rule that checks content and applies fixes based on a condition
386    #[derive(Clone)]
387    struct ConditionalFixRule {
388        name: &'static str,
389        /// Function to check if content has issues
390        check_fn: fn(&str) -> bool,
391        /// Function to fix content
392        fix_fn: fn(&str) -> String,
393    }
394
395    impl Rule for ConditionalFixRule {
396        fn name(&self) -> &'static str {
397            self.name
398        }
399
400        fn check(&self, ctx: &LintContext) -> LintResult {
401            if (self.check_fn)(ctx.content) {
402                Ok(vec![LintWarning {
403                    line: 1,
404                    column: 1,
405                    end_line: 1,
406                    end_column: 1,
407                    message: format!("{} issue found", self.name),
408                    rule_name: Some(self.name.to_string()),
409                    severity: Severity::Error,
410                    fix: Some(Fix {
411                        range: 0..0,
412                        replacement: String::new(),
413                    }),
414                }])
415            } else {
416                Ok(vec![])
417            }
418        }
419
420        fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
421            Ok((self.fix_fn)(ctx.content))
422        }
423
424        fn description(&self) -> &'static str {
425            "Conditional fix rule for testing"
426        }
427
428        fn category(&self) -> RuleCategory {
429            RuleCategory::Whitespace
430        }
431
432        fn as_any(&self) -> &dyn std::any::Any {
433            self
434        }
435    }
436
437    // Simple mock rule for basic tests
438    #[derive(Clone)]
439    struct MockRule {
440        name: &'static str,
441        warnings: Vec<LintWarning>,
442        fix_content: String,
443    }
444
445    impl Rule for MockRule {
446        fn name(&self) -> &'static str {
447            self.name
448        }
449
450        fn check(&self, _ctx: &LintContext) -> LintResult {
451            Ok(self.warnings.clone())
452        }
453
454        fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
455            Ok(self.fix_content.clone())
456        }
457
458        fn description(&self) -> &'static str {
459            "Mock rule for testing"
460        }
461
462        fn category(&self) -> RuleCategory {
463            RuleCategory::Whitespace
464        }
465
466        fn as_any(&self) -> &dyn std::any::Any {
467            self
468        }
469    }
470
471    #[test]
472    fn test_dependency_ordering() {
473        let coordinator = FixCoordinator::new();
474
475        let rules: Vec<Box<dyn Rule>> = vec![
476            Box::new(MockRule {
477                name: "MD009",
478                warnings: vec![],
479                fix_content: "".to_string(),
480            }),
481            Box::new(MockRule {
482                name: "MD013",
483                warnings: vec![],
484                fix_content: "".to_string(),
485            }),
486            Box::new(MockRule {
487                name: "MD010",
488                warnings: vec![],
489                fix_content: "".to_string(),
490            }),
491            Box::new(MockRule {
492                name: "MD007",
493                warnings: vec![],
494                fix_content: "".to_string(),
495            }),
496        ];
497
498        let ordered = coordinator.get_optimal_order(&rules);
499        let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
500
501        // MD010 should come before MD007 (dependency)
502        let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
503        let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
504        assert!(md010_idx < md007_idx, "MD010 should come before MD007");
505
506        // MD013 should come before MD009 (dependency)
507        let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
508        let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
509        assert!(md013_idx < md009_idx, "MD013 should come before MD009");
510    }
511
512    #[test]
513    fn test_single_rule_fix() {
514        let coordinator = FixCoordinator::new();
515
516        // Rule that removes "BAD" from content
517        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
518            name: "RemoveBad",
519            check_fn: |content| content.contains("BAD"),
520            fix_fn: |content| content.replace("BAD", "GOOD"),
521        })];
522
523        let mut content = "This is BAD content".to_string();
524        let config = Config::default();
525
526        let result = coordinator
527            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
528            .unwrap();
529
530        assert_eq!(content, "This is GOOD content");
531        assert_eq!(result.rules_fixed, 1);
532        assert!(result.converged);
533    }
534
535    #[test]
536    fn test_cascading_fixes() {
537        // Simulates MD046 -> MD040 cascade:
538        // Rule1: converts "INDENT" to "FENCE" (like MD046 converting indented to fenced)
539        // Rule2: converts "FENCE" to "FENCE_LANG" (like MD040 adding language)
540        let coordinator = FixCoordinator::new();
541
542        let rules: Vec<Box<dyn Rule>> = vec![
543            Box::new(ConditionalFixRule {
544                name: "Rule1_IndentToFence",
545                check_fn: |content| content.contains("INDENT"),
546                fix_fn: |content| content.replace("INDENT", "FENCE"),
547            }),
548            Box::new(ConditionalFixRule {
549                name: "Rule2_FenceToLang",
550                check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
551                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
552            }),
553        ];
554
555        let mut content = "Code: INDENT".to_string();
556        let config = Config::default();
557
558        let result = coordinator
559            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
560            .unwrap();
561
562        // Should reach final state in one run (internally multiple iterations)
563        assert_eq!(content, "Code: FENCE_LANG");
564        assert_eq!(result.rules_fixed, 2);
565        assert!(result.converged);
566        assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
567    }
568
569    #[test]
570    fn test_indirect_cascade() {
571        // Simulates MD022 -> MD046 -> MD040 indirect cascade:
572        // Rule1: adds "BLANK" (like MD022 adding blank line)
573        // Rule2: only triggers if "BLANK" present, converts "CODE" to "FENCE"
574        // Rule3: converts "FENCE" to "FENCE_LANG"
575        let coordinator = FixCoordinator::new();
576
577        let rules: Vec<Box<dyn Rule>> = vec![
578            Box::new(ConditionalFixRule {
579                name: "Rule1_AddBlank",
580                check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
581                fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
582            }),
583            Box::new(ConditionalFixRule {
584                name: "Rule2_CodeToFence",
585                // Only detects CODE as issue if BLANK is present (simulates CommonMark rule)
586                check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
587                fix_fn: |content| content.replace("CODE", "FENCE"),
588            }),
589            Box::new(ConditionalFixRule {
590                name: "Rule3_AddLang",
591                check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
592                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
593            }),
594        ];
595
596        let mut content = "HEADING CODE".to_string();
597        let config = Config::default();
598
599        let result = coordinator
600            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
601            .unwrap();
602
603        // Key assertion: all fixes applied in single run
604        assert_eq!(content, "HEADING BLANK FENCE_LANG");
605        assert_eq!(result.rules_fixed, 3);
606        assert!(result.converged);
607    }
608
609    #[test]
610    fn test_unfixable_rules_skipped() {
611        let coordinator = FixCoordinator::new();
612
613        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
614            name: "MD001",
615            check_fn: |content| content.contains("BAD"),
616            fix_fn: |content| content.replace("BAD", "GOOD"),
617        })];
618
619        let mut content = "BAD content".to_string();
620        let mut config = Config::default();
621        config.global.unfixable = vec!["MD001".to_string()];
622
623        let result = coordinator
624            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
625            .unwrap();
626
627        assert_eq!(content, "BAD content"); // Should not be changed
628        assert_eq!(result.rules_fixed, 0);
629        assert!(result.converged);
630    }
631
632    #[test]
633    fn test_fixable_allowlist() {
634        let coordinator = FixCoordinator::new();
635
636        let rules: Vec<Box<dyn Rule>> = vec![
637            Box::new(ConditionalFixRule {
638                name: "MD001",
639                check_fn: |content| content.contains("A"),
640                fix_fn: |content| content.replace("A", "X"),
641            }),
642            Box::new(ConditionalFixRule {
643                name: "MD002",
644                check_fn: |content| content.contains("B"),
645                fix_fn: |content| content.replace("B", "Y"),
646            }),
647        ];
648
649        let mut content = "AB".to_string();
650        let mut config = Config::default();
651        config.global.fixable = vec!["MD001".to_string()];
652
653        let result = coordinator
654            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
655            .unwrap();
656
657        assert_eq!(content, "XB"); // Only A->X, B unchanged
658        assert_eq!(result.rules_fixed, 1);
659    }
660
661    #[test]
662    fn test_unfixable_rules_resolved_from_alias() {
663        let coordinator = FixCoordinator::new();
664
665        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
666            name: "MD001",
667            check_fn: |content| content.contains("BAD"),
668            fix_fn: |content| content.replace("BAD", "GOOD"),
669        })];
670
671        let mut content = "BAD content".to_string();
672        let mut config = Config::default();
673        // Use the alias instead of canonical name
674        config.global.unfixable = vec!["heading-increment".to_string()];
675
676        let result = coordinator
677            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
678            .unwrap();
679
680        assert_eq!(content, "BAD content"); // Should not be changed - alias resolved to MD001
681        assert_eq!(result.rules_fixed, 0);
682        assert!(result.converged);
683    }
684
685    #[test]
686    fn test_fixable_allowlist_resolved_from_alias() {
687        let coordinator = FixCoordinator::new();
688
689        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
690            name: "MD001",
691            check_fn: |content| content.contains("BAD"),
692            fix_fn: |content| content.replace("BAD", "GOOD"),
693        })];
694
695        let mut content = "BAD content".to_string();
696        let mut config = Config::default();
697        // Use the alias instead of canonical name
698        config.global.fixable = vec!["heading-increment".to_string()];
699
700        let result = coordinator
701            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
702            .unwrap();
703
704        assert_eq!(content, "GOOD content"); // Alias resolved, rule is in allowlist
705        assert_eq!(result.rules_fixed, 1);
706    }
707
708    #[test]
709    fn test_max_iterations_limit() {
710        let coordinator = FixCoordinator::new();
711
712        // Rule that always changes content (pathological case)
713        static COUNTER: AtomicUsize = AtomicUsize::new(0);
714
715        #[derive(Clone)]
716        struct AlwaysChangeRule;
717        impl Rule for AlwaysChangeRule {
718            fn name(&self) -> &'static str {
719                "AlwaysChange"
720            }
721            fn check(&self, _: &LintContext) -> LintResult {
722                Ok(vec![LintWarning {
723                    line: 1,
724                    column: 1,
725                    end_line: 1,
726                    end_column: 1,
727                    message: "Always".to_string(),
728                    rule_name: Some("AlwaysChange".to_string()),
729                    severity: Severity::Error,
730                    fix: Some(Fix {
731                        range: 0..0,
732                        replacement: String::new(),
733                    }),
734                }])
735            }
736            fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
737                COUNTER.fetch_add(1, Ordering::SeqCst);
738                Ok(format!("{}x", ctx.content))
739            }
740            fn description(&self) -> &'static str {
741                "Always changes"
742            }
743            fn category(&self) -> RuleCategory {
744                RuleCategory::Whitespace
745            }
746            fn as_any(&self) -> &dyn std::any::Any {
747                self
748            }
749        }
750
751        COUNTER.store(0, Ordering::SeqCst);
752        let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
753
754        let mut content = "test".to_string();
755        let config = Config::default();
756
757        let result = coordinator
758            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
759            .unwrap();
760
761        // Should stop at max iterations
762        assert_eq!(result.iterations, 5);
763        assert!(!result.converged);
764        assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
765    }
766
767    #[test]
768    fn test_empty_rules() {
769        let coordinator = FixCoordinator::new();
770        let rules: Vec<Box<dyn Rule>> = vec![];
771
772        let mut content = "unchanged".to_string();
773        let config = Config::default();
774
775        let result = coordinator
776            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
777            .unwrap();
778
779        assert_eq!(result.rules_fixed, 0);
780        assert_eq!(result.iterations, 1);
781        assert!(result.converged);
782        assert_eq!(content, "unchanged");
783    }
784
785    #[test]
786    fn test_no_warnings_no_changes() {
787        let coordinator = FixCoordinator::new();
788
789        // Rule that finds no issues
790        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
791            name: "NoIssues",
792            check_fn: |_| false, // Never finds issues
793            fix_fn: |content| content.to_string(),
794        })];
795
796        let mut content = "clean content".to_string();
797        let config = Config::default();
798
799        let result = coordinator
800            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
801            .unwrap();
802
803        assert_eq!(content, "clean content");
804        assert_eq!(result.rules_fixed, 0);
805        assert!(result.converged);
806    }
807
808    #[test]
809    fn test_oscillation_detection() {
810        // Two rules that fight each other: Rule A changes "foo" → "bar", Rule B changes "bar" → "foo".
811        // The fix loop should detect this as an oscillation cycle and stop early with
812        // conflicting_rules populated rather than running all 100 iterations.
813        let coordinator = FixCoordinator::new();
814
815        let rules: Vec<Box<dyn Rule>> = vec![
816            Box::new(ConditionalFixRule {
817                name: "RuleA",
818                check_fn: |content| content.contains("foo"),
819                fix_fn: |content| content.replace("foo", "bar"),
820            }),
821            Box::new(ConditionalFixRule {
822                name: "RuleB",
823                check_fn: |content| content.contains("bar"),
824                fix_fn: |content| content.replace("bar", "foo"),
825            }),
826        ];
827
828        let mut content = "foo".to_string();
829        let config = Config::default();
830
831        let result = coordinator
832            .apply_fixes_iterative(&rules, &[], &mut content, &config, 100, None)
833            .unwrap();
834
835        // Should detect the cycle quickly, not burn through all 100 iterations.
836        assert!(!result.converged, "Should not converge in an oscillating pair");
837        assert!(
838            result.iterations < 10,
839            "Cycle detection should stop well before max_iterations (got {})",
840            result.iterations
841        );
842
843        // Both conflicting rules should be identified.
844        let mut conflicting = result.conflicting_rules.clone();
845        conflicting.sort();
846        assert_eq!(
847            conflicting,
848            vec!["RuleA".to_string(), "RuleB".to_string()],
849            "Both oscillating rules must be reported"
850        );
851        assert_eq!(
852            result.conflict_cycle,
853            vec!["RuleA".to_string(), "RuleB".to_string()],
854            "Cycle should preserve the observed application order"
855        );
856    }
857
858    #[test]
859    fn test_cyclic_dependencies_handled() {
860        let mut coordinator = FixCoordinator::new();
861
862        // Create a cycle: A -> B -> C -> A
863        coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
864        coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
865        coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
866
867        let rules: Vec<Box<dyn Rule>> = vec![
868            Box::new(MockRule {
869                name: "RuleA",
870                warnings: vec![],
871                fix_content: "".to_string(),
872            }),
873            Box::new(MockRule {
874                name: "RuleB",
875                warnings: vec![],
876                fix_content: "".to_string(),
877            }),
878            Box::new(MockRule {
879                name: "RuleC",
880                warnings: vec![],
881                fix_content: "".to_string(),
882            }),
883        ];
884
885        // Should not panic or infinite loop
886        let ordered = coordinator.get_optimal_order(&rules);
887
888        // Should return all rules despite cycle
889        assert_eq!(ordered.len(), 3);
890    }
891
892    #[test]
893    fn test_fix_is_idempotent() {
894        // This is the key test for issue #271
895        let coordinator = FixCoordinator::new();
896
897        let rules: Vec<Box<dyn Rule>> = vec![
898            Box::new(ConditionalFixRule {
899                name: "Rule1",
900                check_fn: |content| content.contains("A"),
901                fix_fn: |content| content.replace("A", "B"),
902            }),
903            Box::new(ConditionalFixRule {
904                name: "Rule2",
905                check_fn: |content| content.contains("B") && !content.contains("C"),
906                fix_fn: |content| content.replace("B", "BC"),
907            }),
908        ];
909
910        let config = Config::default();
911
912        // First run
913        let mut content1 = "A".to_string();
914        let result1 = coordinator
915            .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
916            .unwrap();
917
918        // Second run on same final content
919        let mut content2 = content1.clone();
920        let result2 = coordinator
921            .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
922            .unwrap();
923
924        // Should be identical (idempotent)
925        assert_eq!(content1, content2);
926        assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
927        assert!(result1.converged);
928        assert!(result2.converged);
929    }
930}