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    pub fn apply_fixes_iterative(
170        &self,
171        rules: &[Box<dyn Rule>],
172        _all_warnings: &[LintWarning], // Kept for API compatibility, but we re-check all rules
173        content: &mut String,
174        config: &Config,
175        max_iterations: usize,
176    ) -> Result<FixResult, String> {
177        // Use the minimum of max_iterations parameter and MAX_ITERATIONS constant
178        let max_iterations = max_iterations.min(MAX_ITERATIONS);
179
180        // Get optimal rule order based on dependencies
181        let ordered_rules = self.get_optimal_order(rules);
182
183        let mut total_fixed = 0;
184        let mut total_ctx_creations = 0;
185        let mut iterations = 0;
186        let mut previous_hash = hash_content(content);
187
188        // Track which rules actually applied fixes
189        let mut fixed_rule_names = HashSet::new();
190
191        // Build set of unfixable rules for quick lookup
192        let unfixable_rules: HashSet<&str> = config.global.unfixable.iter().map(|s| s.as_str()).collect();
193
194        // Build set of fixable rules (if specified)
195        let fixable_rules: HashSet<&str> = config.global.fixable.iter().map(|s| s.as_str()).collect();
196        let has_fixable_allowlist = !fixable_rules.is_empty();
197
198        // Ruff-style fix loop: keep applying fixes until content stabilizes
199        while iterations < max_iterations {
200            iterations += 1;
201
202            // Create fresh context for this iteration
203            let ctx = LintContext::new(content, config.markdown_flavor(), None);
204            total_ctx_creations += 1;
205
206            let mut any_fix_applied = false;
207
208            // Check and fix each rule in dependency order
209            for rule in &ordered_rules {
210                // Skip disabled rules
211                if unfixable_rules.contains(rule.name()) {
212                    continue;
213                }
214                if has_fixable_allowlist && !fixable_rules.contains(rule.name()) {
215                    continue;
216                }
217
218                // Check if this rule has any current warnings
219                let warnings = match rule.check(&ctx) {
220                    Ok(w) => w,
221                    Err(_) => continue,
222                };
223
224                if warnings.is_empty() {
225                    continue;
226                }
227
228                // Check if any warnings are fixable
229                let has_fixable = warnings.iter().any(|w| w.fix.is_some());
230                if !has_fixable {
231                    continue;
232                }
233
234                // Apply fix
235                match rule.fix(&ctx) {
236                    Ok(fixed_content) => {
237                        if fixed_content != *content {
238                            *content = fixed_content;
239                            total_fixed += 1;
240                            any_fix_applied = true;
241                            fixed_rule_names.insert(rule.name().to_string());
242
243                            // Break to re-check all rules with the new content
244                            // This is the key difference from the old approach:
245                            // we always restart from the beginning after a fix
246                            break;
247                        }
248                    }
249                    Err(_) => {
250                        // Error applying fix, continue to next rule
251                        continue;
252                    }
253                }
254            }
255
256            // Check if content has stabilized (hash-based convergence)
257            let current_hash = hash_content(content);
258            if current_hash == previous_hash {
259                // Content unchanged - converged!
260                return Ok(FixResult {
261                    rules_fixed: total_fixed,
262                    iterations,
263                    context_creations: total_ctx_creations,
264                    fixed_rule_names,
265                    converged: true,
266                });
267            }
268            previous_hash = current_hash;
269
270            // If no fixes were applied this iteration, we've converged
271            if !any_fix_applied {
272                return Ok(FixResult {
273                    rules_fixed: total_fixed,
274                    iterations,
275                    context_creations: total_ctx_creations,
276                    fixed_rule_names,
277                    converged: true,
278                });
279            }
280        }
281
282        // Hit max iterations - did not converge
283        Ok(FixResult {
284            rules_fixed: total_fixed,
285            iterations,
286            context_creations: total_ctx_creations,
287            fixed_rule_names,
288            converged: false,
289        })
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
297    use std::sync::atomic::{AtomicUsize, Ordering};
298
299    /// Mock rule that checks content and applies fixes based on a condition
300    #[derive(Clone)]
301    struct ConditionalFixRule {
302        name: &'static str,
303        /// Function to check if content has issues
304        check_fn: fn(&str) -> bool,
305        /// Function to fix content
306        fix_fn: fn(&str) -> String,
307    }
308
309    impl Rule for ConditionalFixRule {
310        fn name(&self) -> &'static str {
311            self.name
312        }
313
314        fn check(&self, ctx: &LintContext) -> LintResult {
315            if (self.check_fn)(ctx.content) {
316                Ok(vec![LintWarning {
317                    line: 1,
318                    column: 1,
319                    end_line: 1,
320                    end_column: 1,
321                    message: format!("{} issue found", self.name),
322                    rule_name: Some(self.name.to_string()),
323                    severity: Severity::Error,
324                    fix: Some(Fix {
325                        range: 0..0,
326                        replacement: String::new(),
327                    }),
328                }])
329            } else {
330                Ok(vec![])
331            }
332        }
333
334        fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
335            Ok((self.fix_fn)(ctx.content))
336        }
337
338        fn description(&self) -> &'static str {
339            "Conditional fix rule for testing"
340        }
341
342        fn category(&self) -> RuleCategory {
343            RuleCategory::Whitespace
344        }
345
346        fn as_any(&self) -> &dyn std::any::Any {
347            self
348        }
349    }
350
351    // Simple mock rule for basic tests
352    #[derive(Clone)]
353    struct MockRule {
354        name: &'static str,
355        warnings: Vec<LintWarning>,
356        fix_content: String,
357    }
358
359    impl Rule for MockRule {
360        fn name(&self) -> &'static str {
361            self.name
362        }
363
364        fn check(&self, _ctx: &LintContext) -> LintResult {
365            Ok(self.warnings.clone())
366        }
367
368        fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
369            Ok(self.fix_content.clone())
370        }
371
372        fn description(&self) -> &'static str {
373            "Mock rule for testing"
374        }
375
376        fn category(&self) -> RuleCategory {
377            RuleCategory::Whitespace
378        }
379
380        fn as_any(&self) -> &dyn std::any::Any {
381            self
382        }
383    }
384
385    #[test]
386    fn test_dependency_ordering() {
387        let coordinator = FixCoordinator::new();
388
389        let rules: Vec<Box<dyn Rule>> = vec![
390            Box::new(MockRule {
391                name: "MD009",
392                warnings: vec![],
393                fix_content: "".to_string(),
394            }),
395            Box::new(MockRule {
396                name: "MD013",
397                warnings: vec![],
398                fix_content: "".to_string(),
399            }),
400            Box::new(MockRule {
401                name: "MD010",
402                warnings: vec![],
403                fix_content: "".to_string(),
404            }),
405            Box::new(MockRule {
406                name: "MD007",
407                warnings: vec![],
408                fix_content: "".to_string(),
409            }),
410        ];
411
412        let ordered = coordinator.get_optimal_order(&rules);
413        let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
414
415        // MD010 should come before MD007 (dependency)
416        let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
417        let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
418        assert!(md010_idx < md007_idx, "MD010 should come before MD007");
419
420        // MD013 should come before MD009 (dependency)
421        let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
422        let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
423        assert!(md013_idx < md009_idx, "MD013 should come before MD009");
424    }
425
426    #[test]
427    fn test_single_rule_fix() {
428        let coordinator = FixCoordinator::new();
429
430        // Rule that removes "BAD" from content
431        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
432            name: "RemoveBad",
433            check_fn: |content| content.contains("BAD"),
434            fix_fn: |content| content.replace("BAD", "GOOD"),
435        })];
436
437        let mut content = "This is BAD content".to_string();
438        let config = Config::default();
439
440        let result = coordinator
441            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
442            .unwrap();
443
444        assert_eq!(content, "This is GOOD content");
445        assert_eq!(result.rules_fixed, 1);
446        assert!(result.converged);
447    }
448
449    #[test]
450    fn test_cascading_fixes() {
451        // Simulates MD046 -> MD040 cascade:
452        // Rule1: converts "INDENT" to "FENCE" (like MD046 converting indented to fenced)
453        // Rule2: converts "FENCE" to "FENCE_LANG" (like MD040 adding language)
454        let coordinator = FixCoordinator::new();
455
456        let rules: Vec<Box<dyn Rule>> = vec![
457            Box::new(ConditionalFixRule {
458                name: "Rule1_IndentToFence",
459                check_fn: |content| content.contains("INDENT"),
460                fix_fn: |content| content.replace("INDENT", "FENCE"),
461            }),
462            Box::new(ConditionalFixRule {
463                name: "Rule2_FenceToLang",
464                check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
465                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
466            }),
467        ];
468
469        let mut content = "Code: INDENT".to_string();
470        let config = Config::default();
471
472        let result = coordinator
473            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10)
474            .unwrap();
475
476        // Should reach final state in one run (internally multiple iterations)
477        assert_eq!(content, "Code: FENCE_LANG");
478        assert_eq!(result.rules_fixed, 2);
479        assert!(result.converged);
480        assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
481    }
482
483    #[test]
484    fn test_indirect_cascade() {
485        // Simulates MD022 -> MD046 -> MD040 indirect cascade:
486        // Rule1: adds "BLANK" (like MD022 adding blank line)
487        // Rule2: only triggers if "BLANK" present, converts "CODE" to "FENCE"
488        // Rule3: converts "FENCE" to "FENCE_LANG"
489        let coordinator = FixCoordinator::new();
490
491        let rules: Vec<Box<dyn Rule>> = vec![
492            Box::new(ConditionalFixRule {
493                name: "Rule1_AddBlank",
494                check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
495                fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
496            }),
497            Box::new(ConditionalFixRule {
498                name: "Rule2_CodeToFence",
499                // Only detects CODE as issue if BLANK is present (simulates CommonMark rule)
500                check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
501                fix_fn: |content| content.replace("CODE", "FENCE"),
502            }),
503            Box::new(ConditionalFixRule {
504                name: "Rule3_AddLang",
505                check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
506                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
507            }),
508        ];
509
510        let mut content = "HEADING CODE".to_string();
511        let config = Config::default();
512
513        let result = coordinator
514            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10)
515            .unwrap();
516
517        // Key assertion: all fixes applied in single run
518        assert_eq!(content, "HEADING BLANK FENCE_LANG");
519        assert_eq!(result.rules_fixed, 3);
520        assert!(result.converged);
521    }
522
523    #[test]
524    fn test_unfixable_rules_skipped() {
525        let coordinator = FixCoordinator::new();
526
527        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
528            name: "MD001",
529            check_fn: |content| content.contains("BAD"),
530            fix_fn: |content| content.replace("BAD", "GOOD"),
531        })];
532
533        let mut content = "BAD content".to_string();
534        let mut config = Config::default();
535        config.global.unfixable = vec!["MD001".to_string()];
536
537        let result = coordinator
538            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
539            .unwrap();
540
541        assert_eq!(content, "BAD content"); // Should not be changed
542        assert_eq!(result.rules_fixed, 0);
543        assert!(result.converged);
544    }
545
546    #[test]
547    fn test_fixable_allowlist() {
548        let coordinator = FixCoordinator::new();
549
550        let rules: Vec<Box<dyn Rule>> = vec![
551            Box::new(ConditionalFixRule {
552                name: "AllowedRule",
553                check_fn: |content| content.contains("A"),
554                fix_fn: |content| content.replace("A", "X"),
555            }),
556            Box::new(ConditionalFixRule {
557                name: "NotAllowedRule",
558                check_fn: |content| content.contains("B"),
559                fix_fn: |content| content.replace("B", "Y"),
560            }),
561        ];
562
563        let mut content = "AB".to_string();
564        let mut config = Config::default();
565        config.global.fixable = vec!["AllowedRule".to_string()];
566
567        let result = coordinator
568            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
569            .unwrap();
570
571        assert_eq!(content, "XB"); // Only A->X, B unchanged
572        assert_eq!(result.rules_fixed, 1);
573    }
574
575    #[test]
576    fn test_max_iterations_limit() {
577        let coordinator = FixCoordinator::new();
578
579        // Rule that always changes content (pathological case)
580        static COUNTER: AtomicUsize = AtomicUsize::new(0);
581
582        #[derive(Clone)]
583        struct AlwaysChangeRule;
584        impl Rule for AlwaysChangeRule {
585            fn name(&self) -> &'static str {
586                "AlwaysChange"
587            }
588            fn check(&self, _: &LintContext) -> LintResult {
589                Ok(vec![LintWarning {
590                    line: 1,
591                    column: 1,
592                    end_line: 1,
593                    end_column: 1,
594                    message: "Always".to_string(),
595                    rule_name: Some("AlwaysChange".to_string()),
596                    severity: Severity::Error,
597                    fix: Some(Fix {
598                        range: 0..0,
599                        replacement: String::new(),
600                    }),
601                }])
602            }
603            fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
604                COUNTER.fetch_add(1, Ordering::SeqCst);
605                Ok(format!("{}x", ctx.content))
606            }
607            fn description(&self) -> &'static str {
608                "Always changes"
609            }
610            fn category(&self) -> RuleCategory {
611                RuleCategory::Whitespace
612            }
613            fn as_any(&self) -> &dyn std::any::Any {
614                self
615            }
616        }
617
618        COUNTER.store(0, Ordering::SeqCst);
619        let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
620
621        let mut content = "test".to_string();
622        let config = Config::default();
623
624        let result = coordinator
625            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
626            .unwrap();
627
628        // Should stop at max iterations
629        assert_eq!(result.iterations, 5);
630        assert!(!result.converged);
631        assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
632    }
633
634    #[test]
635    fn test_empty_rules() {
636        let coordinator = FixCoordinator::new();
637        let rules: Vec<Box<dyn Rule>> = vec![];
638
639        let mut content = "unchanged".to_string();
640        let config = Config::default();
641
642        let result = coordinator
643            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
644            .unwrap();
645
646        assert_eq!(result.rules_fixed, 0);
647        assert_eq!(result.iterations, 1);
648        assert!(result.converged);
649        assert_eq!(content, "unchanged");
650    }
651
652    #[test]
653    fn test_no_warnings_no_changes() {
654        let coordinator = FixCoordinator::new();
655
656        // Rule that finds no issues
657        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
658            name: "NoIssues",
659            check_fn: |_| false, // Never finds issues
660            fix_fn: |content| content.to_string(),
661        })];
662
663        let mut content = "clean content".to_string();
664        let config = Config::default();
665
666        let result = coordinator
667            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
668            .unwrap();
669
670        assert_eq!(content, "clean content");
671        assert_eq!(result.rules_fixed, 0);
672        assert!(result.converged);
673    }
674
675    #[test]
676    fn test_cyclic_dependencies_handled() {
677        let mut coordinator = FixCoordinator::new();
678
679        // Create a cycle: A -> B -> C -> A
680        coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
681        coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
682        coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
683
684        let rules: Vec<Box<dyn Rule>> = vec![
685            Box::new(MockRule {
686                name: "RuleA",
687                warnings: vec![],
688                fix_content: "".to_string(),
689            }),
690            Box::new(MockRule {
691                name: "RuleB",
692                warnings: vec![],
693                fix_content: "".to_string(),
694            }),
695            Box::new(MockRule {
696                name: "RuleC",
697                warnings: vec![],
698                fix_content: "".to_string(),
699            }),
700        ];
701
702        // Should not panic or infinite loop
703        let ordered = coordinator.get_optimal_order(&rules);
704
705        // Should return all rules despite cycle
706        assert_eq!(ordered.len(), 3);
707    }
708
709    #[test]
710    fn test_fix_is_idempotent() {
711        // This is the key test for issue #271
712        let coordinator = FixCoordinator::new();
713
714        let rules: Vec<Box<dyn Rule>> = vec![
715            Box::new(ConditionalFixRule {
716                name: "Rule1",
717                check_fn: |content| content.contains("A"),
718                fix_fn: |content| content.replace("A", "B"),
719            }),
720            Box::new(ConditionalFixRule {
721                name: "Rule2",
722                check_fn: |content| content.contains("B") && !content.contains("C"),
723                fix_fn: |content| content.replace("B", "BC"),
724            }),
725        ];
726
727        let config = Config::default();
728
729        // First run
730        let mut content1 = "A".to_string();
731        let result1 = coordinator
732            .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10)
733            .unwrap();
734
735        // Second run on same final content
736        let mut content2 = content1.clone();
737        let result2 = coordinator
738            .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10)
739            .unwrap();
740
741        // Should be identical (idempotent)
742        assert_eq!(content1, content2);
743        assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
744        assert!(result1.converged);
745        assert!(result2.converged);
746    }
747}