Skip to main content

agnix_core/
fixes.rs

1//! Fix application engine for automatic corrections
2
3use crate::diagnostics::{Diagnostic, FIX_CONFIDENCE_MEDIUM_THRESHOLD, Fix, LintResult};
4use crate::fs::{FileSystem, RealFileSystem};
5use crate::parsers::frontmatter::normalize_line_endings;
6use std::collections::{HashMap, HashSet};
7use std::path::PathBuf;
8use std::sync::Arc;
9
10/// Confidence filter for selecting autofixes.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum FixApplyMode {
13    /// Apply only HIGH-confidence fixes (`>= 0.95`).
14    SafeOnly,
15    /// Apply HIGH and MEDIUM-confidence fixes (`>= 0.75`).
16    SafeAndMedium,
17    /// Apply all fixes (including LOW confidence).
18    All,
19}
20
21/// Options for autofix application.
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct FixApplyOptions {
24    pub dry_run: bool,
25    pub mode: FixApplyMode,
26}
27
28impl FixApplyOptions {
29    pub fn new(dry_run: bool, mode: FixApplyMode) -> Self {
30        Self { dry_run, mode }
31    }
32}
33
34/// Result of applying fixes to a file.
35///
36/// # Line endings
37///
38/// Both `original` and `fixed` hold LF-normalized content (CRLF and lone CR are
39/// converted to LF before fixes are applied). Files on disk that used CRLF endings
40/// will be written back with LF endings as a side effect of fix application. This is
41/// intentional and consistent with the rest of the validation pipeline.
42#[derive(Debug, Clone)]
43pub struct FixResult {
44    /// Path to the file
45    pub path: PathBuf,
46    /// Original file content (LF-normalized; may differ from raw on-disk bytes for CRLF files)
47    pub original: String,
48    /// Content after fixes applied (LF-normalized)
49    pub fixed: String,
50    /// Descriptions of applied fixes
51    pub applied: Vec<String>,
52}
53
54impl FixResult {
55    /// Check if any fixes were actually applied
56    pub fn has_changes(&self) -> bool {
57        self.original != self.fixed
58    }
59}
60
61/// Apply fixes from diagnostics to files
62///
63/// # Arguments
64/// * `diagnostics` - Diagnostics with potential fixes
65/// * `dry_run` - If true, compute fixes but don't write files
66/// * `safe_only` - If true, only apply fixes marked as safe
67///
68/// # Returns
69/// Vector of fix results, one per file that had fixes
70pub fn apply_fixes(
71    diagnostics: &[Diagnostic],
72    dry_run: bool,
73    safe_only: bool,
74) -> LintResult<Vec<FixResult>> {
75    let mode = if safe_only {
76        FixApplyMode::SafeOnly
77    } else {
78        // Backwards-compatible behavior for this stable API:
79        // when `safe_only=false`, apply all fixes.
80        FixApplyMode::All
81    };
82    apply_fixes_with_options(diagnostics, FixApplyOptions::new(dry_run, mode))
83}
84
85/// Apply fixes from diagnostics to files with optional FileSystem abstraction
86///
87/// # Arguments
88/// * `diagnostics` - Diagnostics with potential fixes
89/// * `dry_run` - If true, compute fixes but don't write files
90/// * `safe_only` - If true, only apply fixes marked as safe
91/// * `fs` - Optional FileSystem for reading/writing files. If None, uses RealFileSystem.
92///
93/// # Returns
94/// Vector of fix results, one per file that had fixes
95pub fn apply_fixes_with_fs(
96    diagnostics: &[Diagnostic],
97    dry_run: bool,
98    safe_only: bool,
99    fs: Option<Arc<dyn FileSystem>>,
100) -> LintResult<Vec<FixResult>> {
101    let mode = if safe_only {
102        FixApplyMode::SafeOnly
103    } else {
104        FixApplyMode::All
105    };
106    apply_fixes_with_fs_options(diagnostics, FixApplyOptions::new(dry_run, mode), fs)
107}
108
109/// Apply fixes using explicit options.
110pub fn apply_fixes_with_options(
111    diagnostics: &[Diagnostic],
112    options: FixApplyOptions,
113) -> LintResult<Vec<FixResult>> {
114    apply_fixes_with_fs_options(diagnostics, options, None)
115}
116
117/// Apply fixes using explicit options and an optional file system abstraction.
118///
119/// File content is CRLF-normalized before fixes are applied, so byte offsets in
120/// [`Fix`] objects must reference LF-normalized positions (as produced by
121/// `validate_content` or `validate_file`). Files with CRLF endings
122/// will be written back with LF endings.
123pub fn apply_fixes_with_fs_options(
124    diagnostics: &[Diagnostic],
125    options: FixApplyOptions,
126    fs: Option<Arc<dyn FileSystem>>,
127) -> LintResult<Vec<FixResult>> {
128    let fs = fs.unwrap_or_else(|| Arc::new(RealFileSystem));
129
130    // Group diagnostics by file
131    let mut by_file: HashMap<PathBuf, Vec<&Diagnostic>> = HashMap::new();
132    for diag in diagnostics {
133        if diag.has_fixes() {
134            by_file.entry(diag.file.clone()).or_default().push(diag);
135        }
136    }
137
138    let mut results = Vec::new();
139
140    for (path, file_diagnostics) in by_file {
141        let raw_content = fs.read_to_string(&path)?;
142        // Match on the Cow to avoid a second scan: Borrowed means LF-only (reuse the
143        // already-owned String), Owned means normalization was needed.
144        let original = match normalize_line_endings(&raw_content) {
145            std::borrow::Cow::Borrowed(_) => raw_content,
146            std::borrow::Cow::Owned(normalized) => normalized,
147        };
148
149        let mut fixes = select_fixes(&file_diagnostics, options.mode);
150
151        if fixes.is_empty() {
152            continue;
153        }
154
155        // Sort descending to apply from end (preserves earlier positions)
156        fixes.sort_by(|a, b| b.start_byte.cmp(&a.start_byte));
157
158        let (fixed, applied) = apply_fixes_to_content(&original, &fixes);
159
160        if fixed != original {
161            if !options.dry_run {
162                fs.write(&path, &fixed)?;
163            }
164
165            results.push(FixResult {
166                path,
167                original,
168                fixed,
169                applied,
170            });
171        }
172    }
173
174    results.sort_by(|a, b| a.path.cmp(&b.path));
175
176    Ok(results)
177}
178
179/// Apply fixes to content string, returning new content and applied descriptions.
180/// Fixes must be sorted by start_byte descending to preserve positions.
181fn apply_fixes_to_content(content: &str, fixes: &[&Fix]) -> (String, Vec<String>) {
182    let mut result = content.to_string();
183    let mut applied = Vec::new();
184    let mut last_start = usize::MAX;
185    let (planned_groups, planned_descriptions) = planned_dependency_keys(content, fixes);
186
187    for fix in fixes {
188        if let Some(depends_on) = fix.depends_on.as_deref() {
189            let satisfied =
190                planned_groups.contains(depends_on) || planned_descriptions.contains(depends_on);
191            if !satisfied {
192                continue;
193            }
194        }
195
196        // Check end_byte > start_byte first (more fundamental invariant)
197        if fix.end_byte < fix.start_byte {
198            // Log: Invalid fix range (end before start)
199            continue;
200        }
201        // Then check bounds
202        if fix.start_byte > result.len() || fix.end_byte > result.len() {
203            // Log: Fix out of bounds
204            continue;
205        }
206        // Check UTF-8 boundaries
207        if !result.is_char_boundary(fix.start_byte) || !result.is_char_boundary(fix.end_byte) {
208            // Log: UTF-8 boundary violation - this indicates a bug in fix generation
209            // The fix byte offsets don't align with character boundaries
210            continue;
211        }
212        // Skip overlapping fixes (sorted descending, so check against previous fix start)
213        if fix.end_byte > last_start {
214            // Log: Skipping overlapping fix
215            continue;
216        }
217
218        result.replace_range(fix.start_byte..fix.end_byte, &fix.replacement);
219        applied.push(fix.description.clone());
220        last_start = fix.start_byte;
221    }
222
223    applied.reverse();
224
225    (result, applied)
226}
227
228fn select_fixes<'a>(file_diagnostics: &'a [&'a Diagnostic], mode: FixApplyMode) -> Vec<&'a Fix> {
229    let candidates: Vec<&Fix> = file_diagnostics
230        .iter()
231        .flat_map(|d| d.fixes.iter())
232        .filter(|fix| should_apply_by_mode(fix, mode))
233        .collect();
234
235    let dependency_resolved = resolve_dependency_candidates(candidates);
236    select_group_alternatives(dependency_resolved)
237}
238
239fn should_apply_by_mode(fix: &Fix, mode: FixApplyMode) -> bool {
240    match mode {
241        FixApplyMode::SafeOnly => fix.is_safe(),
242        FixApplyMode::SafeAndMedium => fix.confidence_score() >= FIX_CONFIDENCE_MEDIUM_THRESHOLD,
243        FixApplyMode::All => true,
244    }
245}
246
247fn resolve_dependency_candidates(mut fixes: Vec<&Fix>) -> Vec<&Fix> {
248    // Cap iterations to the number of fixes with dependencies (+1 for convergence
249    // check). This matches the algorithm's actual termination condition better
250    // than total fix count, and avoids wasted iterations for large fix sets
251    // with shallow dependency chains.
252    let deps_count = fixes.iter().filter(|f| f.depends_on.is_some()).count();
253    let max_iterations = deps_count.saturating_add(1);
254    for _ in 0..max_iterations {
255        let groups: HashSet<&str> = fixes.iter().filter_map(|f| f.group.as_deref()).collect();
256        let descriptions: HashSet<&str> = fixes.iter().map(|f| f.description.as_str()).collect();
257        let before = fixes.len();
258
259        fixes.retain(|fix| match fix.depends_on.as_deref() {
260            Some(depends_on) => groups.contains(depends_on) || descriptions.contains(depends_on),
261            None => true,
262        });
263
264        if fixes.len() == before {
265            break;
266        }
267    }
268
269    fixes
270}
271
272fn select_group_alternatives(fixes: Vec<&Fix>) -> Vec<&Fix> {
273    let mut selected_groups: HashSet<&str> = HashSet::new();
274    let mut selected = Vec::new();
275
276    for fix in fixes {
277        if let Some(group) = fix.group.as_deref() {
278            if selected_groups.contains(group) {
279                continue;
280            }
281            selected_groups.insert(group);
282        }
283
284        selected.push(fix);
285    }
286
287    selected
288}
289
290fn planned_dependency_keys<'a>(
291    content: &str,
292    fixes: &[&'a Fix],
293) -> (HashSet<&'a str>, HashSet<&'a str>) {
294    let mut groups = HashSet::new();
295    let mut descriptions = HashSet::new();
296    let mut last_start = usize::MAX;
297
298    for fix in fixes {
299        if !is_fix_range_applicable(content, fix) {
300            continue;
301        }
302
303        if fix.end_byte > last_start {
304            continue;
305        }
306
307        descriptions.insert(fix.description.as_str());
308        if let Some(group) = fix.group.as_deref() {
309            groups.insert(group);
310        }
311        last_start = fix.start_byte;
312    }
313
314    (groups, descriptions)
315}
316
317fn is_fix_range_applicable(content: &str, fix: &Fix) -> bool {
318    if fix.end_byte < fix.start_byte {
319        return false;
320    }
321
322    if fix.start_byte > content.len() || fix.end_byte > content.len() {
323        return false;
324    }
325
326    content.is_char_boundary(fix.start_byte) && content.is_char_boundary(fix.end_byte)
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::diagnostics::{DiagnosticLevel, Fix};
333
334    fn make_diagnostic(path: &str, fixes: Vec<Fix>) -> Diagnostic {
335        Diagnostic {
336            level: DiagnosticLevel::Error,
337            message: "Test error".to_string(),
338            file: PathBuf::from(path),
339            line: 1,
340            column: 1,
341            rule: "TEST-001".to_string(),
342            suggestion: None,
343            fixes,
344            assumption: None,
345            metadata: None,
346        }
347    }
348
349    #[test]
350    fn test_fix_single_replacement() {
351        let content = "name: Bad_Name";
352        let fix = Fix::replace(6, 14, "good-name", "Fix name format", true);
353
354        let (result, applied) = apply_fixes_to_content(content, &[&fix]);
355
356        assert_eq!(result, "name: good-name");
357        assert_eq!(applied.len(), 1);
358        assert_eq!(applied[0], "Fix name format");
359    }
360
361    #[test]
362    fn test_fix_insertion() {
363        let content = "hello world";
364        let fix = Fix::insert(5, " beautiful", "Add word", true);
365
366        let (result, _) = apply_fixes_to_content(content, &[&fix]);
367
368        assert_eq!(result, "hello beautiful world");
369    }
370
371    #[test]
372    fn test_fix_deletion() {
373        let content = "hello beautiful world";
374        let fix = Fix::delete(5, 15, "Remove word", true);
375
376        let (result, _) = apply_fixes_to_content(content, &[&fix]);
377
378        assert_eq!(result, "hello world");
379    }
380
381    #[test]
382    fn test_fix_multiple_non_overlapping() {
383        let content = "aaa bbb ccc";
384        let fixes = vec![
385            Fix::replace(0, 3, "AAA", "Uppercase first", true),
386            Fix::replace(8, 11, "CCC", "Uppercase last", true),
387        ];
388        let fix_refs: Vec<&Fix> = fixes.iter().collect();
389
390        // Sort descending by start_byte (as apply_fixes does)
391        let mut sorted = fix_refs.clone();
392        sorted.sort_by(|a, b| b.start_byte.cmp(&a.start_byte));
393
394        let (result, applied) = apply_fixes_to_content(content, &sorted);
395
396        assert_eq!(result, "AAA bbb CCC");
397        assert_eq!(applied.len(), 2);
398    }
399
400    #[test]
401    fn test_fix_reverse_order_preserves_positions() {
402        // When we have fixes at positions 0-3 and 8-11,
403        // applying 8-11 first keeps position 0-3 valid
404        let content = "foo bar baz";
405        let fixes = vec![
406            Fix::replace(0, 3, "FOO", "Fix 1", true),
407            Fix::replace(8, 11, "BAZ", "Fix 2", true),
408        ];
409
410        // Sort descending (8-11 first, then 0-3)
411        let mut sorted: Vec<&Fix> = fixes.iter().collect();
412        sorted.sort_by(|a, b| b.start_byte.cmp(&a.start_byte));
413
414        let (result, _) = apply_fixes_to_content(content, &sorted);
415
416        assert_eq!(result, "FOO bar BAZ");
417    }
418
419    #[test]
420    fn test_fix_safe_only_filter() {
421        let temp = tempfile::TempDir::new().unwrap();
422        let path = temp.path().join("test.md");
423        std::fs::write(&path, "name: Bad_Name").unwrap();
424
425        let diagnostics = vec![make_diagnostic(
426            path.to_str().unwrap(),
427            vec![
428                Fix::replace(6, 14, "safe-name", "Safe fix", true),
429                Fix::replace(0, 4, "NAME", "Unsafe fix", false),
430            ],
431        )];
432
433        // With safe_only = true, only the safe fix should apply
434        let results = apply_fixes(&diagnostics, false, true).unwrap();
435
436        assert_eq!(results.len(), 1);
437        assert_eq!(results[0].fixed, "name: safe-name");
438        assert_eq!(results[0].applied.len(), 1);
439    }
440
441    #[test]
442    fn test_fix_mode_safe_and_medium_filters_low() {
443        let temp = tempfile::TempDir::new().unwrap();
444        let path = temp.path().join("test.md");
445        std::fs::write(&path, "abcdef").unwrap();
446
447        let diagnostics = vec![make_diagnostic(
448            path.to_str().unwrap(),
449            vec![
450                Fix::replace_with_confidence(0, 1, "A", "high", 0.99),
451                Fix::replace_with_confidence(1, 2, "B", "medium", 0.80),
452                Fix::replace_with_confidence(2, 3, "C", "low", 0.20),
453            ],
454        )];
455
456        let results = apply_fixes_with_options(
457            &diagnostics,
458            FixApplyOptions::new(false, FixApplyMode::SafeAndMedium),
459        )
460        .unwrap();
461
462        assert_eq!(results.len(), 1);
463        assert_eq!(results[0].fixed, "ABcdef");
464        assert_eq!(results[0].applied, vec!["high", "medium"]);
465    }
466
467    #[test]
468    fn test_fix_mode_safe_only_applies_high_only() {
469        let temp = tempfile::TempDir::new().unwrap();
470        let path = temp.path().join("test.md");
471        std::fs::write(&path, "abcdef").unwrap();
472
473        let diagnostics = vec![make_diagnostic(
474            path.to_str().unwrap(),
475            vec![
476                Fix::replace_with_confidence(0, 1, "A", "high", 0.99),
477                Fix::replace_with_confidence(1, 2, "B", "medium", 0.80),
478                Fix::replace_with_confidence(2, 3, "C", "low", 0.20),
479            ],
480        )];
481
482        let results = apply_fixes_with_options(
483            &diagnostics,
484            FixApplyOptions::new(false, FixApplyMode::SafeOnly),
485        )
486        .unwrap();
487
488        assert_eq!(results.len(), 1);
489        assert_eq!(results[0].fixed, "Abcdef");
490        assert_eq!(results[0].applied, vec!["high"]);
491    }
492
493    #[test]
494    fn test_fix_group_applies_first_alternative_only() {
495        let content = "hello";
496        let first = Fix::insert(5, "!", "first", true).with_group("punctuation");
497        let second = Fix::insert(5, "?", "second", true).with_group("punctuation");
498
499        // Keep source order to confirm first alternative wins.
500        let fixes = vec![&first, &second];
501        let (fixed, applied) = apply_fixes_to_content(content, &fixes);
502
503        // Engine-level group handling happens before this function, so emulate selection.
504        let diagnostic = make_diagnostic(
505            "x.md",
506            vec![
507                Fix::insert(5, "!", "first", true).with_group("punctuation"),
508                Fix::insert(5, "?", "second", true).with_group("punctuation"),
509            ],
510        );
511        let diagnostics = [&diagnostic];
512        let selected = select_fixes(&diagnostics, FixApplyMode::All);
513        let mut refs = selected;
514        refs.sort_by(|a, b| b.start_byte.cmp(&a.start_byte));
515        let (selected_fixed, selected_applied) = apply_fixes_to_content(content, &refs);
516
517        assert_eq!(fixed, "hello?!");
518        assert_eq!(applied.len(), 2);
519        assert_eq!(selected_fixed, "hello!");
520        assert_eq!(selected_applied, vec!["first"]);
521    }
522
523    #[test]
524    fn test_fix_dependency_requires_predecessor() {
525        let content = "foo bar";
526        let prerequisite = Fix::replace(4, 7, "BAR", "normalize-tail", true).with_group("step1");
527        let dependent = Fix::replace(0, 3, "FOO", "normalize-head", true).with_dependency("step1");
528        let orphan = Fix::replace(0, 3, "XXX", "orphan", true).with_dependency("missing");
529
530        let mut refs = vec![&prerequisite, &dependent];
531        refs.sort_by(|a, b| b.start_byte.cmp(&a.start_byte));
532        let (fixed, applied) = apply_fixes_to_content(content, &refs);
533        assert_eq!(fixed, "FOO BAR");
534        assert_eq!(applied, vec!["normalize-head", "normalize-tail"]);
535
536        let mut orphan_refs = vec![&orphan];
537        orphan_refs.sort_by(|a, b| b.start_byte.cmp(&a.start_byte));
538        let (orphan_fixed, orphan_applied) = apply_fixes_to_content(content, &orphan_refs);
539        assert_eq!(orphan_fixed, content);
540        assert!(orphan_applied.is_empty());
541    }
542
543    #[test]
544    fn test_fix_dependency_not_order_sensitive() {
545        let content = "foo bar";
546        let prerequisite = Fix::replace(0, 3, "FOO", "normalize-head", true).with_group("step1");
547        let dependent = Fix::replace(4, 7, "BAR", "normalize-tail", true).with_dependency("step1");
548
549        // Descending sort puts dependent first, but dependency should still be satisfied.
550        let mut refs = vec![&prerequisite, &dependent];
551        refs.sort_by(|a, b| b.start_byte.cmp(&a.start_byte));
552        let (fixed, applied) = apply_fixes_to_content(content, &refs);
553
554        assert_eq!(fixed, "FOO BAR");
555        assert_eq!(applied, vec!["normalize-head", "normalize-tail"]);
556    }
557
558    #[test]
559    fn test_fix_dependency_can_reference_description() {
560        let content = "foo bar";
561        let prerequisite = Fix::replace(0, 3, "FOO", "normalize-head", true);
562        let dependent =
563            Fix::replace(4, 7, "BAR", "normalize-tail", true).with_dependency("normalize-head");
564
565        let mut refs = vec![&prerequisite, &dependent];
566        refs.sort_by(|a, b| b.start_byte.cmp(&a.start_byte));
567        let (fixed, applied) = apply_fixes_to_content(content, &refs);
568
569        assert_eq!(fixed, "FOO BAR");
570        assert_eq!(applied, vec!["normalize-head", "normalize-tail"]);
571    }
572
573    #[test]
574    fn test_fix_dependency_skips_when_prerequisite_would_be_invalid() {
575        let content = "foo bar";
576        let prerequisite =
577            Fix::replace(10, 11, "X", "invalid-prerequisite", true).with_group("step1");
578        let dependent = Fix::replace(4, 7, "BAR", "normalize-tail", true).with_dependency("step1");
579
580        let mut refs = vec![&prerequisite, &dependent];
581        refs.sort_by(|a, b| b.start_byte.cmp(&a.start_byte));
582        let (fixed, applied) = apply_fixes_to_content(content, &refs);
583
584        assert_eq!(fixed, content);
585        assert!(applied.is_empty());
586    }
587
588    #[test]
589    fn test_fix_group_falls_back_when_first_candidate_is_removed() {
590        let diagnostic = make_diagnostic(
591            "x.md",
592            vec![
593                Fix::insert(5, "!", "first", true)
594                    .with_group("punctuation")
595                    .with_dependency("missing"),
596                Fix::insert(5, "?", "second", true).with_group("punctuation"),
597            ],
598        );
599        let diagnostics = [&diagnostic];
600        let selected = select_fixes(&diagnostics, FixApplyMode::All);
601
602        assert_eq!(selected.len(), 1);
603        assert_eq!(selected[0].description, "second");
604    }
605
606    #[test]
607    fn test_fix_dry_run_no_write() {
608        let temp = tempfile::TempDir::new().unwrap();
609        let path = temp.path().join("test.md");
610        let original = "name: Bad_Name";
611        std::fs::write(&path, original).unwrap();
612
613        let diagnostics = vec![make_diagnostic(
614            path.to_str().unwrap(),
615            vec![Fix::replace(6, 14, "good-name", "Fix name", true)],
616        )];
617
618        // Dry run
619        let results = apply_fixes(&diagnostics, true, false).unwrap();
620
621        // Results should show the fix
622        assert_eq!(results.len(), 1);
623        assert_eq!(results[0].fixed, "name: good-name");
624
625        // But file should be unchanged
626        let file_content = std::fs::read_to_string(&path).unwrap();
627        assert_eq!(file_content, original);
628    }
629
630    #[test]
631    fn test_fix_actual_write() {
632        let temp = tempfile::TempDir::new().unwrap();
633        let path = temp.path().join("test.md");
634        std::fs::write(&path, "name: Bad_Name").unwrap();
635
636        let diagnostics = vec![make_diagnostic(
637            path.to_str().unwrap(),
638            vec![Fix::replace(6, 14, "good-name", "Fix name", true)],
639        )];
640
641        // Actually apply
642        let results = apply_fixes(&diagnostics, false, false).unwrap();
643
644        assert_eq!(results.len(), 1);
645
646        // File should be modified
647        let file_content = std::fs::read_to_string(&path).unwrap();
648        assert_eq!(file_content, "name: good-name");
649    }
650
651    #[test]
652    fn test_fix_invalid_positions_skipped() {
653        let content = "short";
654        let fix = Fix::replace(100, 200, "won't apply", "Bad fix", true);
655
656        let (result, applied) = apply_fixes_to_content(content, &[&fix]);
657
658        assert_eq!(result, "short");
659        assert!(applied.is_empty());
660    }
661
662    #[test]
663    fn test_fix_empty_diagnostics() {
664        let results = apply_fixes(&[], false, false).unwrap();
665        assert!(results.is_empty());
666    }
667
668    #[test]
669    fn test_fix_no_fixes_in_diagnostics() {
670        let diagnostics = vec![Diagnostic {
671            level: DiagnosticLevel::Error,
672            message: "No fix available".to_string(),
673            file: PathBuf::from("test.md"),
674            line: 1,
675            column: 1,
676            rule: "TEST-001".to_string(),
677            suggestion: None,
678            fixes: Vec::new(),
679            assumption: None,
680            metadata: None,
681        }];
682
683        let results = apply_fixes(&diagnostics, false, false).unwrap();
684        assert!(results.is_empty());
685    }
686
687    #[test]
688    fn test_fix_result_has_changes() {
689        let result_with_changes = FixResult {
690            path: PathBuf::from("test.md"),
691            original: "old".to_string(),
692            fixed: "new".to_string(),
693            applied: vec!["Fix".to_string()],
694        };
695        assert!(result_with_changes.has_changes());
696
697        let result_no_changes = FixResult {
698            path: PathBuf::from("test.md"),
699            original: "same".to_string(),
700            fixed: "same".to_string(),
701            applied: vec![],
702        };
703        assert!(!result_no_changes.has_changes());
704    }
705
706    #[test]
707    fn test_fix_overlapping_skipped() {
708        let content = "hello world";
709        // Overlapping fixes: first at 6-11, second at 4-8
710        // Sorted descending: 6-11 first, then 4-8
711        // 4-8 overlaps with 6-11 (end_byte 8 > start 6), should be skipped
712        let fixes = vec![
713            Fix::replace(6, 11, "universe", "Fix 1", true),
714            Fix::replace(4, 8, "XXX", "Fix 2 overlaps", true),
715        ];
716
717        let mut sorted: Vec<&Fix> = fixes.iter().collect();
718        sorted.sort_by(|a, b| b.start_byte.cmp(&a.start_byte));
719
720        let (result, applied) = apply_fixes_to_content(content, &sorted);
721
722        assert_eq!(result, "hello universe");
723        assert_eq!(applied.len(), 1);
724        assert_eq!(applied[0], "Fix 1");
725    }
726
727    // ===== MockFileSystem Integration Tests =====
728
729    #[test]
730    fn test_apply_fixes_with_mock_fs_dry_run() {
731        use crate::fs::MockFileSystem;
732
733        let mock_fs = MockFileSystem::new();
734        mock_fs.add_file("/project/test.md", "name: Bad_Name");
735
736        let diagnostics = vec![make_diagnostic(
737            "/project/test.md",
738            vec![Fix::replace(6, 14, "good-name", "Fix name", true)],
739        )];
740
741        // Dry run with mock filesystem
742        let results =
743            apply_fixes_with_fs(&diagnostics, true, false, Some(Arc::new(mock_fs))).unwrap();
744
745        assert_eq!(results.len(), 1);
746        assert_eq!(results[0].original, "name: Bad_Name");
747        assert_eq!(results[0].fixed, "name: good-name");
748        assert!(results[0].has_changes());
749
750        // File should be unchanged (dry run)
751        // Note: We can't verify this directly since we passed ownership,
752        // but the logic is tested - dry_run=true means no write() call
753    }
754
755    #[test]
756    fn test_apply_fixes_with_mock_fs_actual_write() {
757        use crate::fs::{FileSystem, MockFileSystem};
758
759        let mock_fs = Arc::new(MockFileSystem::new());
760        mock_fs.add_file("/project/test.md", "name: Bad_Name");
761
762        let diagnostics = vec![make_diagnostic(
763            "/project/test.md",
764            vec![Fix::replace(6, 14, "good-name", "Fix name", true)],
765        )];
766
767        // Clone as trait object for apply_fixes_with_fs
768        let fs_clone: Arc<dyn FileSystem> = Arc::clone(&mock_fs) as Arc<dyn FileSystem>;
769
770        // Actually apply with mock filesystem
771        let results = apply_fixes_with_fs(&diagnostics, false, false, Some(fs_clone)).unwrap();
772
773        assert_eq!(results.len(), 1);
774        assert_eq!(results[0].fixed, "name: good-name");
775
776        // Verify mock filesystem was updated
777        let content = mock_fs
778            .read_to_string(std::path::Path::new("/project/test.md"))
779            .unwrap();
780        assert_eq!(content, "name: good-name");
781    }
782
783    #[test]
784    fn test_apply_fixes_with_mock_fs_safe_only() {
785        use crate::fs::{FileSystem, MockFileSystem};
786
787        let mock_fs = Arc::new(MockFileSystem::new());
788        mock_fs.add_file("/project/test.md", "name: Bad_Name");
789
790        let diagnostics = vec![make_diagnostic(
791            "/project/test.md",
792            vec![
793                Fix::replace(6, 14, "safe-name", "Safe fix", true),
794                Fix::replace(0, 4, "NAME", "Unsafe fix", false),
795            ],
796        )];
797
798        // Clone as trait object for apply_fixes_with_fs
799        let fs_clone: Arc<dyn FileSystem> = Arc::clone(&mock_fs) as Arc<dyn FileSystem>;
800
801        // Apply with safe_only = true
802        let results = apply_fixes_with_fs(&diagnostics, false, true, Some(fs_clone)).unwrap();
803
804        assert_eq!(results.len(), 1);
805        // Only safe fix should be applied
806        assert_eq!(results[0].fixed, "name: safe-name");
807        assert_eq!(results[0].applied.len(), 1);
808        assert_eq!(results[0].applied[0], "Safe fix");
809
810        // Verify mock filesystem reflects only safe fix
811        let content = mock_fs
812            .read_to_string(std::path::Path::new("/project/test.md"))
813            .unwrap();
814        assert_eq!(content, "name: safe-name");
815    }
816
817    #[test]
818    fn test_apply_fixes_with_mock_fs_multiple_files() {
819        use crate::fs::{FileSystem, MockFileSystem};
820
821        let mock_fs = Arc::new(MockFileSystem::new());
822        mock_fs.add_file("/project/file1.md", "old1");
823        mock_fs.add_file("/project/file2.md", "old2");
824
825        let diagnostics = vec![
826            make_diagnostic(
827                "/project/file1.md",
828                vec![Fix::replace(0, 4, "new1", "Fix file1", true)],
829            ),
830            make_diagnostic(
831                "/project/file2.md",
832                vec![Fix::replace(0, 4, "new2", "Fix file2", true)],
833            ),
834        ];
835
836        // Clone as trait object for apply_fixes_with_fs
837        let fs_clone: Arc<dyn FileSystem> = Arc::clone(&mock_fs) as Arc<dyn FileSystem>;
838
839        let results = apply_fixes_with_fs(&diagnostics, false, false, Some(fs_clone)).unwrap();
840
841        assert_eq!(results.len(), 2);
842
843        // Verify both files were updated
844        let content1 = mock_fs
845            .read_to_string(std::path::Path::new("/project/file1.md"))
846            .unwrap();
847        let content2 = mock_fs
848            .read_to_string(std::path::Path::new("/project/file2.md"))
849            .unwrap();
850
851        assert_eq!(content1, "new1");
852        assert_eq!(content2, "new2");
853    }
854
855    #[test]
856    fn test_apply_fixes_with_mock_fs_file_not_found() {
857        use crate::fs::MockFileSystem;
858
859        let mock_fs = MockFileSystem::new();
860        // Don't add the file - it should error
861
862        let diagnostics = vec![make_diagnostic(
863            "/project/nonexistent.md",
864            vec![Fix::replace(0, 4, "new", "Fix", true)],
865        )];
866
867        let result = apply_fixes_with_fs(&diagnostics, false, false, Some(Arc::new(mock_fs)));
868
869        assert!(result.is_err());
870    }
871
872    #[test]
873    fn test_apply_fixes_with_mock_fs_no_changes() {
874        use crate::fs::MockFileSystem;
875
876        let mock_fs = MockFileSystem::new();
877        mock_fs.add_file("/project/test.md", "unchanged");
878
879        // Diagnostic with no fixes
880        let diagnostics = vec![Diagnostic {
881            level: DiagnosticLevel::Error,
882            message: "No fix available".to_string(),
883            file: PathBuf::from("/project/test.md"),
884            line: 1,
885            column: 1,
886            rule: "TEST-001".to_string(),
887            suggestion: None,
888            fixes: Vec::new(),
889            assumption: None,
890            metadata: None,
891        }];
892
893        let results =
894            apply_fixes_with_fs(&diagnostics, false, false, Some(Arc::new(mock_fs))).unwrap();
895
896        // No fixes means no results
897        assert!(results.is_empty());
898    }
899
900    // ===== Edge Case: Ordering and Overlap Tests =====
901
902    #[test]
903    fn test_fix_three_non_overlapping_descending() {
904        let content = "aaaa_bbbb_cccc_dddd_eeee_ffff";
905        // Fixes at positions 20-24, 10-14, 0-4 (already sorted descending)
906        let fixes = vec![
907            Fix::replace(20, 24, "EEEE", "Fix third", true),
908            Fix::replace(10, 14, "CCCC", "Fix second", true),
909            Fix::replace(0, 4, "AAAA", "Fix first", true),
910        ];
911        let fix_refs: Vec<&Fix> = fixes.iter().collect();
912
913        let (result, applied) = apply_fixes_to_content(content, &fix_refs);
914
915        assert_eq!(applied.len(), 3);
916        assert!(result.contains("AAAA"));
917        assert!(result.contains("CCCC"));
918        assert!(result.contains("EEEE"));
919    }
920
921    #[test]
922    fn test_fix_overlapping_middle_skipped() {
923        let content = "0123456789abcdef";
924        // Sorted descending: 10-14 first, then 6-12 (overlaps), then 0-4
925        let fixes = vec![
926            Fix::replace(10, 14, "XX", "Fix at 10", true),
927            Fix::replace(6, 12, "YY", "Fix at 6 (overlaps)", true),
928            Fix::replace(0, 4, "ZZ", "Fix at 0", true),
929        ];
930        let fix_refs: Vec<&Fix> = fixes.iter().collect();
931
932        let (result, applied) = apply_fixes_to_content(content, &fix_refs);
933
934        // Fix at 10-14 applied, fix at 6-12 skipped (end_byte 12 > last_start 10),
935        // fix at 0-4 applied (end_byte 4 < last_start 10, but after the overlap skip,
936        // last_start is still 10 from the first fix)
937        assert_eq!(
938            applied.len(),
939            2,
940            "Only two non-overlapping fixes should apply"
941        );
942        assert!(result.contains("XX"), "Fix at 10-14 should be applied");
943        assert!(result.contains("ZZ"), "Fix at 0-4 should be applied");
944        assert!(
945            !result.contains("YY"),
946            "Overlapping fix at 6-12 should be skipped"
947        );
948    }
949
950    #[test]
951    fn test_fix_adjacent_fixes() {
952        let content = "hello world";
953        // Sorted descending: (5,6) first, then (0,5)
954        // end_byte 5 == last_start 5 -> NOT > so both should apply
955        let fixes = vec![
956            Fix::replace(5, 6, "_", "Replace space", true),
957            Fix::replace(0, 5, "HELLO", "Uppercase first word", true),
958        ];
959        let fix_refs: Vec<&Fix> = fixes.iter().collect();
960
961        let (result, applied) = apply_fixes_to_content(content, &fix_refs);
962
963        assert_eq!(applied.len(), 2, "Adjacent fixes should both apply");
964        assert_eq!(result, "HELLO_world");
965    }
966
967    #[test]
968    fn test_fix_same_position_insertions() {
969        let content = "hello";
970        // Two insertions at the same position. Their application order depends on their
971        // order in the slice, as the sort by `start_byte` is stable.
972        let fixes = vec![
973            Fix::insert(5, "!", "Add exclamation", true),
974            Fix::insert(5, "?", "Add question", true),
975        ];
976        let mut fix_refs: Vec<&Fix> = fixes.iter().collect();
977        // `apply_fixes_to_content` expects fixes to be sorted descending by start_byte.
978        fix_refs.sort_by(|a, b| b.start_byte.cmp(&a.start_byte));
979
980        let (result, applied) = apply_fixes_to_content(content, &fix_refs);
981
982        assert_eq!(
983            applied.len(),
984            2,
985            "Same-position insertions should both apply"
986        );
987
988        // The fixes are applied in the order they appear in the sorted slice.
989        // Since the sort is stable for equal `start_byte`, "!" is applied first, then "?".
990        // 1. "hello" -> "hello!"
991        // 2. "hello!" -> "hello?!" (at index 5 of the modified string)
992        assert_eq!(result, "hello?!");
993    }
994
995    #[test]
996    fn test_fix_length_changing_preserves_positions() {
997        let content = "short___long_text";
998        // Sorted descending: 12-17 first, then 0-5
999        let fixes = vec![
1000            Fix::replace(12, 17, "replacement_that_is_longer", "Fix second", true),
1001            Fix::replace(0, 5, "S", "Fix first", true),
1002        ];
1003        let fix_refs: Vec<&Fix> = fixes.iter().collect();
1004
1005        let (result, applied) = apply_fixes_to_content(content, &fix_refs);
1006
1007        assert_eq!(applied.len(), 2, "Both fixes should apply");
1008        assert!(
1009            result.starts_with("S"),
1010            "First 5 chars should be replaced with 'S'"
1011        );
1012        assert!(
1013            result.contains("replacement_that_is_longer"),
1014            "Later range should be replaced"
1015        );
1016    }
1017
1018    #[test]
1019    fn test_fix_end_before_start_skipped() {
1020        let content = "hello";
1021        // Invalid fix: start_byte > end_byte (end_byte < start_byte)
1022        let fix = Fix {
1023            start_byte: 3,
1024            end_byte: 1,
1025            replacement: "X".to_string(),
1026            description: "Invalid fix".to_string(),
1027            safe: true,
1028            confidence: Some(1.0),
1029            group: None,
1030            depends_on: None,
1031        };
1032
1033        let (result, applied) = apply_fixes_to_content(content, &[&fix]);
1034
1035        assert_eq!(result, "hello", "Content should be unchanged");
1036        assert!(applied.is_empty(), "Invalid fix should be skipped");
1037    }
1038
1039    #[test]
1040    fn test_fix_unicode_content_boundaries() {
1041        // \u{00e9} is precomposed e-acute: 2 bytes (0xc3, 0xa9)
1042        let content = "caf\u{00e9} is great";
1043        // 'c'=0, 'a'=1, 'f'=2, '\u{00e9}'=3-4 (2 bytes), ' '=5
1044        // Fix byte 3 to 5 (the e-acute), replace with "e"
1045        let fix = Fix::replace(3, 5, "e", "Normalize accent", true);
1046
1047        let (result, applied) = apply_fixes_to_content(content, &[&fix]);
1048
1049        assert_eq!(result, "cafe is great");
1050        assert_eq!(applied.len(), 1);
1051    }
1052
1053    #[test]
1054    fn test_fix_crlf_content() {
1055        let content = "hello\r\nworld";
1056        // Replace \r\n (bytes 5-7) with \n
1057        let fix = Fix::replace(5, 7, "\n", "Normalize line ending", true);
1058
1059        let (result, applied) = apply_fixes_to_content(content, &[&fix]);
1060
1061        assert_eq!(result, "hello\nworld");
1062        assert_eq!(applied.len(), 1);
1063    }
1064
1065    #[test]
1066    fn test_apply_fixes_with_mock_fs_crlf_normalization() {
1067        // Verify that apply_fixes_with_fs_options normalizes CRLF before applying fixes.
1068        // The fix byte offsets are computed against LF-normalized content (as validators see it).
1069        use crate::fs::MockFileSystem;
1070
1071        let mock_fs = MockFileSystem::new();
1072        // File on disk has CRLF endings: "name:\r\n bad-name"
1073        // After normalization: "name:\n bad-name"
1074        //   byte 0..5 = "name:", byte 5 = '\n', byte 6 = ' ', byte 7..15 = "bad-name"
1075        // Fix replaces "bad-name" (bytes 7..15 in normalized form) with "good-name"
1076        mock_fs.add_file("/project/skill.md", "name:\r\n bad-name");
1077
1078        let diagnostics = vec![make_diagnostic(
1079            "/project/skill.md",
1080            vec![Fix::replace(7, 15, "good-name", "Fix name", true)],
1081        )];
1082
1083        let results =
1084            apply_fixes_with_fs(&diagnostics, true, false, Some(Arc::new(mock_fs))).unwrap();
1085
1086        assert_eq!(results.len(), 1, "Should produce one FixResult");
1087        assert!(
1088            !results[0].original.contains('\r'),
1089            "FixResult.original should be LF-normalized (no \\r)"
1090        );
1091        assert_eq!(results[0].original, "name:\n bad-name");
1092        assert_eq!(results[0].fixed, "name:\n good-name");
1093        assert!(results[0].has_changes());
1094    }
1095
1096    #[test]
1097    fn test_apply_fixes_with_mock_fs_crlf_no_actual_changes() {
1098        // A CRLF file where the only applicable fix leaves content unchanged after normalization
1099        // should not appear in FixResult. This exercises the normalization code path
1100        // (the file IS read and normalize_line_endings IS called) via a fix that produces
1101        // no net change on the normalized content.
1102        use crate::fs::{FileSystem, MockFileSystem};
1103
1104        let mock_fs = Arc::new(MockFileSystem::new());
1105        // CRLF file: after normalization "name:\n good-name" - fix replaces "good-name" with itself
1106        mock_fs.add_file("/project/skill.md", "name:\r\n good-name");
1107
1108        let fs_clone: Arc<dyn FileSystem> = Arc::clone(&mock_fs) as Arc<dyn FileSystem>;
1109
1110        let diagnostics = vec![make_diagnostic(
1111            "/project/skill.md",
1112            // Replace "good-name" (bytes 7..16 in normalized "name:\n good-name") with the same text
1113            vec![Fix::replace(7, 16, "good-name", "No-op fix", true)],
1114        )];
1115
1116        let results = apply_fixes_with_fs(&diagnostics, false, false, Some(fs_clone)).unwrap();
1117
1118        // fixed == original because the fix produces no net change: no FixResult emitted
1119        assert!(
1120            results.is_empty(),
1121            "No FixResult should be emitted when fix produces no net change on normalized content"
1122        );
1123    }
1124
1125    #[test]
1126    fn test_apply_fixes_with_mock_fs_crlf_actual_write() {
1127        // Verify the non-dry-run path: apply_fixes_with_fs writes LF-normalized content to disk.
1128        use crate::fs::{FileSystem, MockFileSystem};
1129
1130        let mock_fs = Arc::new(MockFileSystem::new());
1131        // File on disk has CRLF endings
1132        mock_fs.add_file("/project/skill.md", "name:\r\n bad-name");
1133
1134        let fs_clone: Arc<dyn FileSystem> = Arc::clone(&mock_fs) as Arc<dyn FileSystem>;
1135
1136        let diagnostics = vec![make_diagnostic(
1137            "/project/skill.md",
1138            vec![Fix::replace(7, 15, "good-name", "Fix name", true)],
1139        )];
1140
1141        let results = apply_fixes_with_fs(&diagnostics, false, false, Some(fs_clone)).unwrap();
1142
1143        assert_eq!(results.len(), 1);
1144        assert_eq!(results[0].fixed, "name:\n good-name");
1145
1146        // The written file should contain LF-normalized content
1147        let written = mock_fs
1148            .read_to_string(std::path::Path::new("/project/skill.md"))
1149            .unwrap();
1150        assert!(
1151            !written.contains('\r'),
1152            "Written file should have no \\r (LF-normalized)"
1153        );
1154        assert_eq!(written, "name:\n good-name");
1155    }
1156
1157    #[test]
1158    fn test_fix_utf8_boundary_skip() {
1159        // \u{00e9} = 2 bytes: 0xc3 at byte 3, 0xa9 at byte 4
1160        let content = "caf\u{00e9}";
1161        // Fix starting at byte 4 (mid-codepoint continuation byte) should be skipped
1162        let fix = Fix::replace(4, 5, "X", "Mid-codepoint fix", true);
1163
1164        let (result, applied) = apply_fixes_to_content(content, &[&fix]);
1165
1166        assert_eq!(
1167            result, "caf\u{00e9}",
1168            "Content should be unchanged when fix targets mid-codepoint"
1169        );
1170        assert!(
1171            applied.is_empty(),
1172            "Fix at non-char-boundary should be skipped"
1173        );
1174    }
1175
1176    #[test]
1177    fn test_fix_replacement_with_unicode() {
1178        let content = "hello world";
1179        let fix = Fix::replace(6, 11, "\u{4e16}\u{754c}", "Replace with CJK", true);
1180
1181        let (result, applied) = apply_fixes_to_content(content, &[&fix]);
1182
1183        assert_eq!(result, "hello \u{4e16}\u{754c}");
1184        assert_eq!(applied.len(), 1);
1185    }
1186}