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}
28
29/// Calculate hash of content for convergence detection
30fn hash_content(content: &str) -> u64 {
31    let mut hasher = DefaultHasher::new();
32    content.hash(&mut hasher);
33    hasher.finish()
34}
35
36/// Coordinates rule fixing to minimize the number of passes needed
37pub struct FixCoordinator {
38    /// Rules that should run before others (rule -> rules that depend on it)
39    dependencies: HashMap<&'static str, Vec<&'static str>>,
40}
41
42impl Default for FixCoordinator {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48impl FixCoordinator {
49    pub fn new() -> Self {
50        let mut dependencies = HashMap::new();
51
52        // CRITICAL DEPENDENCIES:
53        // These dependencies prevent cascading issues that require multiple passes
54
55        // MD064 (multiple consecutive spaces) MUST run before:
56        // - MD010 (tabs->spaces) - MD010 replaces tabs with multiple spaces (e.g., 4),
57        //   which MD064 would incorrectly collapse back to 1 space if it ran after
58        dependencies.insert("MD064", vec!["MD010"]);
59
60        // MD010 (tabs->spaces) MUST run before:
61        // - MD007 (list indentation) - because tabs affect indent calculation
62        // - MD005 (list indent consistency) - same reason
63        dependencies.insert("MD010", vec!["MD007", "MD005"]);
64
65        // MD013 (line length) MUST run before:
66        // - MD009 (trailing spaces) - line wrapping might add trailing spaces that need cleanup
67        // - MD012 (multiple blanks) - reflowing can affect blank lines
68        // Note: MD013 now trims trailing whitespace during reflow to prevent mid-line spaces
69        dependencies.insert("MD013", vec!["MD009", "MD012"]);
70
71        // MD004 (list style) should run before:
72        // - MD007 (list indentation) - changing markers affects indentation
73        dependencies.insert("MD004", vec!["MD007"]);
74
75        // MD022/MD023 (heading spacing) should run before:
76        // - MD012 (multiple blanks) - heading fixes can affect blank lines
77        dependencies.insert("MD022", vec!["MD012"]);
78        dependencies.insert("MD023", vec!["MD012"]);
79
80        // MD070 (nested fence collision) MUST run before:
81        // - MD040 (code language) - MD070 changes block structure, making orphan fences into content
82        // - MD031 (blanks around fences) - same reason
83        dependencies.insert("MD070", vec!["MD040", "MD031"]);
84
85        Self { dependencies }
86    }
87
88    /// Get the optimal order for running rules based on dependencies
89    pub fn get_optimal_order<'a>(&self, rules: &'a [Box<dyn Rule>]) -> Vec<&'a dyn Rule> {
90        // Build a map of rule names to rules for quick lookup
91        let rule_map: HashMap<&str, &dyn Rule> = rules.iter().map(|r| (r.name(), r.as_ref())).collect();
92
93        // Build reverse dependencies (rule -> rules it depends on)
94        let mut reverse_deps: HashMap<&str, HashSet<&str>> = HashMap::new();
95        for (prereq, dependents) in &self.dependencies {
96            for dependent in dependents {
97                reverse_deps.entry(dependent).or_default().insert(prereq);
98            }
99        }
100
101        // Perform topological sort
102        let mut sorted = Vec::new();
103        let mut visited = HashSet::new();
104        let mut visiting = HashSet::new();
105
106        fn visit<'a>(
107            rule_name: &str,
108            rule_map: &HashMap<&str, &'a dyn Rule>,
109            reverse_deps: &HashMap<&str, HashSet<&str>>,
110            visited: &mut HashSet<String>,
111            visiting: &mut HashSet<String>,
112            sorted: &mut Vec<&'a dyn Rule>,
113        ) {
114            if visited.contains(rule_name) {
115                return;
116            }
117
118            if visiting.contains(rule_name) {
119                // Cycle detected, but we'll just skip it
120                return;
121            }
122
123            visiting.insert(rule_name.to_string());
124
125            // Visit dependencies first
126            if let Some(deps) = reverse_deps.get(rule_name) {
127                for dep in deps {
128                    if rule_map.contains_key(dep) {
129                        visit(dep, rule_map, reverse_deps, visited, visiting, sorted);
130                    }
131                }
132            }
133
134            visiting.remove(rule_name);
135            visited.insert(rule_name.to_string());
136
137            // Add this rule to sorted list
138            if let Some(&rule) = rule_map.get(rule_name) {
139                sorted.push(rule);
140            }
141        }
142
143        // Visit all rules
144        for rule in rules {
145            visit(
146                rule.name(),
147                &rule_map,
148                &reverse_deps,
149                &mut visited,
150                &mut visiting,
151                &mut sorted,
152            );
153        }
154
155        // Add any rules not in dependency graph
156        for rule in rules {
157            if !sorted.iter().any(|r| r.name() == rule.name()) {
158                sorted.push(rule.as_ref());
159            }
160        }
161
162        sorted
163    }
164
165    /// Apply fixes iteratively until no more fixes are needed or max iterations reached.
166    ///
167    /// This implements a Ruff-inspired fix loop that re-checks ALL rules after each fix
168    /// to detect cascading issues (e.g., MD046 creating code blocks that MD040 needs to fix).
169    ///
170    /// The `file_path` parameter is used to determine per-file flavor overrides. If provided,
171    /// the flavor for creating LintContext will be resolved using `config.get_flavor_for_file()`.
172    pub fn apply_fixes_iterative(
173        &self,
174        rules: &[Box<dyn Rule>],
175        _all_warnings: &[LintWarning], // Kept for API compatibility, but we re-check all rules
176        content: &mut String,
177        config: &Config,
178        max_iterations: usize,
179        file_path: Option<&std::path::Path>,
180    ) -> Result<FixResult, String> {
181        // Use the minimum of max_iterations parameter and MAX_ITERATIONS constant
182        let max_iterations = max_iterations.min(MAX_ITERATIONS);
183
184        // Get optimal rule order based on dependencies
185        let ordered_rules = self.get_optimal_order(rules);
186
187        let mut total_fixed = 0;
188        let mut total_ctx_creations = 0;
189        let mut iterations = 0;
190        let mut previous_hash = hash_content(content);
191
192        // Track which rules actually applied fixes
193        let mut fixed_rule_names = HashSet::new();
194
195        // Build set of unfixable rules for quick lookup
196        let unfixable_rules: HashSet<&str> = config.global.unfixable.iter().map(|s| s.as_str()).collect();
197
198        // Build set of fixable rules (if specified)
199        let fixable_rules: HashSet<&str> = config.global.fixable.iter().map(|s| s.as_str()).collect();
200        let has_fixable_allowlist = !fixable_rules.is_empty();
201
202        // Ruff-style fix loop: keep applying fixes until content stabilizes
203        while iterations < max_iterations {
204            iterations += 1;
205
206            // Create fresh context for this iteration
207            // Use per-file flavor if file_path is provided, otherwise fall back to global flavor
208            let flavor = file_path
209                .map(|p| config.get_flavor_for_file(p))
210                .unwrap_or_else(|| config.markdown_flavor());
211            let ctx = LintContext::new(content, flavor, None);
212            total_ctx_creations += 1;
213
214            let mut any_fix_applied = false;
215
216            // Check and fix each rule in dependency order
217            for rule in &ordered_rules {
218                // Skip disabled rules
219                if unfixable_rules.contains(rule.name()) {
220                    continue;
221                }
222                if has_fixable_allowlist && !fixable_rules.contains(rule.name()) {
223                    continue;
224                }
225
226                // Skip rules that indicate they should be skipped (opt-in rules, content-based skipping)
227                if rule.should_skip(&ctx) {
228                    continue;
229                }
230
231                // Check if this rule has any current warnings
232                let warnings = match rule.check(&ctx) {
233                    Ok(w) => w,
234                    Err(_) => continue,
235                };
236
237                if warnings.is_empty() {
238                    continue;
239                }
240
241                // Check if any warnings are fixable
242                let has_fixable = warnings.iter().any(|w| w.fix.is_some());
243                if !has_fixable {
244                    continue;
245                }
246
247                // Apply fix
248                match rule.fix(&ctx) {
249                    Ok(fixed_content) => {
250                        if fixed_content != *content {
251                            *content = fixed_content;
252                            total_fixed += 1;
253                            any_fix_applied = true;
254                            fixed_rule_names.insert(rule.name().to_string());
255
256                            // Break to re-check all rules with the new content
257                            // This is the key difference from the old approach:
258                            // we always restart from the beginning after a fix
259                            break;
260                        }
261                    }
262                    Err(_) => {
263                        // Error applying fix, continue to next rule
264                        continue;
265                    }
266                }
267            }
268
269            // Check if content has stabilized (hash-based convergence)
270            let current_hash = hash_content(content);
271            if current_hash == previous_hash {
272                // Content unchanged - converged!
273                return Ok(FixResult {
274                    rules_fixed: total_fixed,
275                    iterations,
276                    context_creations: total_ctx_creations,
277                    fixed_rule_names,
278                    converged: true,
279                });
280            }
281            previous_hash = current_hash;
282
283            // If no fixes were applied this iteration, we've converged
284            if !any_fix_applied {
285                return Ok(FixResult {
286                    rules_fixed: total_fixed,
287                    iterations,
288                    context_creations: total_ctx_creations,
289                    fixed_rule_names,
290                    converged: true,
291                });
292            }
293        }
294
295        // Hit max iterations - did not converge
296        Ok(FixResult {
297            rules_fixed: total_fixed,
298            iterations,
299            context_creations: total_ctx_creations,
300            fixed_rule_names,
301            converged: false,
302        })
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
310    use std::sync::atomic::{AtomicUsize, Ordering};
311
312    /// Mock rule that checks content and applies fixes based on a condition
313    #[derive(Clone)]
314    struct ConditionalFixRule {
315        name: &'static str,
316        /// Function to check if content has issues
317        check_fn: fn(&str) -> bool,
318        /// Function to fix content
319        fix_fn: fn(&str) -> String,
320    }
321
322    impl Rule for ConditionalFixRule {
323        fn name(&self) -> &'static str {
324            self.name
325        }
326
327        fn check(&self, ctx: &LintContext) -> LintResult {
328            if (self.check_fn)(ctx.content) {
329                Ok(vec![LintWarning {
330                    line: 1,
331                    column: 1,
332                    end_line: 1,
333                    end_column: 1,
334                    message: format!("{} issue found", self.name),
335                    rule_name: Some(self.name.to_string()),
336                    severity: Severity::Error,
337                    fix: Some(Fix {
338                        range: 0..0,
339                        replacement: String::new(),
340                    }),
341                }])
342            } else {
343                Ok(vec![])
344            }
345        }
346
347        fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
348            Ok((self.fix_fn)(ctx.content))
349        }
350
351        fn description(&self) -> &'static str {
352            "Conditional fix rule for testing"
353        }
354
355        fn category(&self) -> RuleCategory {
356            RuleCategory::Whitespace
357        }
358
359        fn as_any(&self) -> &dyn std::any::Any {
360            self
361        }
362    }
363
364    // Simple mock rule for basic tests
365    #[derive(Clone)]
366    struct MockRule {
367        name: &'static str,
368        warnings: Vec<LintWarning>,
369        fix_content: String,
370    }
371
372    impl Rule for MockRule {
373        fn name(&self) -> &'static str {
374            self.name
375        }
376
377        fn check(&self, _ctx: &LintContext) -> LintResult {
378            Ok(self.warnings.clone())
379        }
380
381        fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
382            Ok(self.fix_content.clone())
383        }
384
385        fn description(&self) -> &'static str {
386            "Mock rule for testing"
387        }
388
389        fn category(&self) -> RuleCategory {
390            RuleCategory::Whitespace
391        }
392
393        fn as_any(&self) -> &dyn std::any::Any {
394            self
395        }
396    }
397
398    #[test]
399    fn test_dependency_ordering() {
400        let coordinator = FixCoordinator::new();
401
402        let rules: Vec<Box<dyn Rule>> = vec![
403            Box::new(MockRule {
404                name: "MD009",
405                warnings: vec![],
406                fix_content: "".to_string(),
407            }),
408            Box::new(MockRule {
409                name: "MD013",
410                warnings: vec![],
411                fix_content: "".to_string(),
412            }),
413            Box::new(MockRule {
414                name: "MD010",
415                warnings: vec![],
416                fix_content: "".to_string(),
417            }),
418            Box::new(MockRule {
419                name: "MD007",
420                warnings: vec![],
421                fix_content: "".to_string(),
422            }),
423        ];
424
425        let ordered = coordinator.get_optimal_order(&rules);
426        let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
427
428        // MD010 should come before MD007 (dependency)
429        let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
430        let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
431        assert!(md010_idx < md007_idx, "MD010 should come before MD007");
432
433        // MD013 should come before MD009 (dependency)
434        let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
435        let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
436        assert!(md013_idx < md009_idx, "MD013 should come before MD009");
437    }
438
439    #[test]
440    fn test_single_rule_fix() {
441        let coordinator = FixCoordinator::new();
442
443        // Rule that removes "BAD" from content
444        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
445            name: "RemoveBad",
446            check_fn: |content| content.contains("BAD"),
447            fix_fn: |content| content.replace("BAD", "GOOD"),
448        })];
449
450        let mut content = "This is BAD content".to_string();
451        let config = Config::default();
452
453        let result = coordinator
454            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
455            .unwrap();
456
457        assert_eq!(content, "This is GOOD content");
458        assert_eq!(result.rules_fixed, 1);
459        assert!(result.converged);
460    }
461
462    #[test]
463    fn test_cascading_fixes() {
464        // Simulates MD046 -> MD040 cascade:
465        // Rule1: converts "INDENT" to "FENCE" (like MD046 converting indented to fenced)
466        // Rule2: converts "FENCE" to "FENCE_LANG" (like MD040 adding language)
467        let coordinator = FixCoordinator::new();
468
469        let rules: Vec<Box<dyn Rule>> = vec![
470            Box::new(ConditionalFixRule {
471                name: "Rule1_IndentToFence",
472                check_fn: |content| content.contains("INDENT"),
473                fix_fn: |content| content.replace("INDENT", "FENCE"),
474            }),
475            Box::new(ConditionalFixRule {
476                name: "Rule2_FenceToLang",
477                check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
478                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
479            }),
480        ];
481
482        let mut content = "Code: INDENT".to_string();
483        let config = Config::default();
484
485        let result = coordinator
486            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
487            .unwrap();
488
489        // Should reach final state in one run (internally multiple iterations)
490        assert_eq!(content, "Code: FENCE_LANG");
491        assert_eq!(result.rules_fixed, 2);
492        assert!(result.converged);
493        assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
494    }
495
496    #[test]
497    fn test_indirect_cascade() {
498        // Simulates MD022 -> MD046 -> MD040 indirect cascade:
499        // Rule1: adds "BLANK" (like MD022 adding blank line)
500        // Rule2: only triggers if "BLANK" present, converts "CODE" to "FENCE"
501        // Rule3: converts "FENCE" to "FENCE_LANG"
502        let coordinator = FixCoordinator::new();
503
504        let rules: Vec<Box<dyn Rule>> = vec![
505            Box::new(ConditionalFixRule {
506                name: "Rule1_AddBlank",
507                check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
508                fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
509            }),
510            Box::new(ConditionalFixRule {
511                name: "Rule2_CodeToFence",
512                // Only detects CODE as issue if BLANK is present (simulates CommonMark rule)
513                check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
514                fix_fn: |content| content.replace("CODE", "FENCE"),
515            }),
516            Box::new(ConditionalFixRule {
517                name: "Rule3_AddLang",
518                check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
519                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
520            }),
521        ];
522
523        let mut content = "HEADING CODE".to_string();
524        let config = Config::default();
525
526        let result = coordinator
527            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
528            .unwrap();
529
530        // Key assertion: all fixes applied in single run
531        assert_eq!(content, "HEADING BLANK FENCE_LANG");
532        assert_eq!(result.rules_fixed, 3);
533        assert!(result.converged);
534    }
535
536    #[test]
537    fn test_unfixable_rules_skipped() {
538        let coordinator = FixCoordinator::new();
539
540        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
541            name: "MD001",
542            check_fn: |content| content.contains("BAD"),
543            fix_fn: |content| content.replace("BAD", "GOOD"),
544        })];
545
546        let mut content = "BAD content".to_string();
547        let mut config = Config::default();
548        config.global.unfixable = vec!["MD001".to_string()];
549
550        let result = coordinator
551            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
552            .unwrap();
553
554        assert_eq!(content, "BAD content"); // Should not be changed
555        assert_eq!(result.rules_fixed, 0);
556        assert!(result.converged);
557    }
558
559    #[test]
560    fn test_fixable_allowlist() {
561        let coordinator = FixCoordinator::new();
562
563        let rules: Vec<Box<dyn Rule>> = vec![
564            Box::new(ConditionalFixRule {
565                name: "AllowedRule",
566                check_fn: |content| content.contains("A"),
567                fix_fn: |content| content.replace("A", "X"),
568            }),
569            Box::new(ConditionalFixRule {
570                name: "NotAllowedRule",
571                check_fn: |content| content.contains("B"),
572                fix_fn: |content| content.replace("B", "Y"),
573            }),
574        ];
575
576        let mut content = "AB".to_string();
577        let mut config = Config::default();
578        config.global.fixable = vec!["AllowedRule".to_string()];
579
580        let result = coordinator
581            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
582            .unwrap();
583
584        assert_eq!(content, "XB"); // Only A->X, B unchanged
585        assert_eq!(result.rules_fixed, 1);
586    }
587
588    #[test]
589    fn test_max_iterations_limit() {
590        let coordinator = FixCoordinator::new();
591
592        // Rule that always changes content (pathological case)
593        static COUNTER: AtomicUsize = AtomicUsize::new(0);
594
595        #[derive(Clone)]
596        struct AlwaysChangeRule;
597        impl Rule for AlwaysChangeRule {
598            fn name(&self) -> &'static str {
599                "AlwaysChange"
600            }
601            fn check(&self, _: &LintContext) -> LintResult {
602                Ok(vec![LintWarning {
603                    line: 1,
604                    column: 1,
605                    end_line: 1,
606                    end_column: 1,
607                    message: "Always".to_string(),
608                    rule_name: Some("AlwaysChange".to_string()),
609                    severity: Severity::Error,
610                    fix: Some(Fix {
611                        range: 0..0,
612                        replacement: String::new(),
613                    }),
614                }])
615            }
616            fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
617                COUNTER.fetch_add(1, Ordering::SeqCst);
618                Ok(format!("{}x", ctx.content))
619            }
620            fn description(&self) -> &'static str {
621                "Always changes"
622            }
623            fn category(&self) -> RuleCategory {
624                RuleCategory::Whitespace
625            }
626            fn as_any(&self) -> &dyn std::any::Any {
627                self
628            }
629        }
630
631        COUNTER.store(0, Ordering::SeqCst);
632        let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
633
634        let mut content = "test".to_string();
635        let config = Config::default();
636
637        let result = coordinator
638            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
639            .unwrap();
640
641        // Should stop at max iterations
642        assert_eq!(result.iterations, 5);
643        assert!(!result.converged);
644        assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
645    }
646
647    #[test]
648    fn test_empty_rules() {
649        let coordinator = FixCoordinator::new();
650        let rules: Vec<Box<dyn Rule>> = vec![];
651
652        let mut content = "unchanged".to_string();
653        let config = Config::default();
654
655        let result = coordinator
656            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
657            .unwrap();
658
659        assert_eq!(result.rules_fixed, 0);
660        assert_eq!(result.iterations, 1);
661        assert!(result.converged);
662        assert_eq!(content, "unchanged");
663    }
664
665    #[test]
666    fn test_no_warnings_no_changes() {
667        let coordinator = FixCoordinator::new();
668
669        // Rule that finds no issues
670        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
671            name: "NoIssues",
672            check_fn: |_| false, // Never finds issues
673            fix_fn: |content| content.to_string(),
674        })];
675
676        let mut content = "clean content".to_string();
677        let config = Config::default();
678
679        let result = coordinator
680            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
681            .unwrap();
682
683        assert_eq!(content, "clean content");
684        assert_eq!(result.rules_fixed, 0);
685        assert!(result.converged);
686    }
687
688    #[test]
689    fn test_cyclic_dependencies_handled() {
690        let mut coordinator = FixCoordinator::new();
691
692        // Create a cycle: A -> B -> C -> A
693        coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
694        coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
695        coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
696
697        let rules: Vec<Box<dyn Rule>> = vec![
698            Box::new(MockRule {
699                name: "RuleA",
700                warnings: vec![],
701                fix_content: "".to_string(),
702            }),
703            Box::new(MockRule {
704                name: "RuleB",
705                warnings: vec![],
706                fix_content: "".to_string(),
707            }),
708            Box::new(MockRule {
709                name: "RuleC",
710                warnings: vec![],
711                fix_content: "".to_string(),
712            }),
713        ];
714
715        // Should not panic or infinite loop
716        let ordered = coordinator.get_optimal_order(&rules);
717
718        // Should return all rules despite cycle
719        assert_eq!(ordered.len(), 3);
720    }
721
722    #[test]
723    fn test_fix_is_idempotent() {
724        // This is the key test for issue #271
725        let coordinator = FixCoordinator::new();
726
727        let rules: Vec<Box<dyn Rule>> = vec![
728            Box::new(ConditionalFixRule {
729                name: "Rule1",
730                check_fn: |content| content.contains("A"),
731                fix_fn: |content| content.replace("A", "B"),
732            }),
733            Box::new(ConditionalFixRule {
734                name: "Rule2",
735                check_fn: |content| content.contains("B") && !content.contains("C"),
736                fix_fn: |content| content.replace("B", "BC"),
737            }),
738        ];
739
740        let config = Config::default();
741
742        // First run
743        let mut content1 = "A".to_string();
744        let result1 = coordinator
745            .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
746            .unwrap();
747
748        // Second run on same final content
749        let mut content2 = content1.clone();
750        let result2 = coordinator
751            .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
752            .unwrap();
753
754        // Should be identical (idempotent)
755        assert_eq!(content1, content2);
756        assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
757        assert!(result1.converged);
758        assert!(result2.converged);
759    }
760}