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