1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum FixApplyMode {
13 SafeOnly,
15 SafeAndMedium,
17 All,
19}
20
21#[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#[derive(Debug, Clone)]
43pub struct FixResult {
44 pub path: PathBuf,
46 pub original: String,
48 pub fixed: String,
50 pub applied: Vec<String>,
52}
53
54impl FixResult {
55 pub fn has_changes(&self) -> bool {
57 self.original != self.fixed
58 }
59}
60
61pub 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 FixApplyMode::All
81 };
82 apply_fixes_with_options(diagnostics, FixApplyOptions::new(dry_run, mode))
83}
84
85pub 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
109pub 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
117pub 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 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 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 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
179fn 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 if fix.end_byte < fix.start_byte {
198 continue;
200 }
201 if fix.start_byte > result.len() || fix.end_byte > result.len() {
203 continue;
205 }
206 if !result.is_char_boundary(fix.start_byte) || !result.is_char_boundary(fix.end_byte) {
208 continue;
211 }
212 if fix.end_byte > last_start {
214 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 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 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 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 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 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 let fixes = vec![&first, &second];
501 let (fixed, applied) = apply_fixes_to_content(content, &fixes);
502
503 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 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 let results = apply_fixes(&diagnostics, true, false).unwrap();
620
621 assert_eq!(results.len(), 1);
623 assert_eq!(results[0].fixed, "name: good-name");
624
625 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 let results = apply_fixes(&diagnostics, false, false).unwrap();
643
644 assert_eq!(results.len(), 1);
645
646 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 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 #[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 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 }
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 let fs_clone: Arc<dyn FileSystem> = Arc::clone(&mock_fs) as Arc<dyn FileSystem>;
769
770 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 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 let fs_clone: Arc<dyn FileSystem> = Arc::clone(&mock_fs) as Arc<dyn FileSystem>;
800
801 let results = apply_fixes_with_fs(&diagnostics, false, true, Some(fs_clone)).unwrap();
803
804 assert_eq!(results.len(), 1);
805 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 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 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 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 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 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 assert!(results.is_empty());
898 }
899
900 #[test]
903 fn test_fix_three_non_overlapping_descending() {
904 let content = "aaaa_bbbb_cccc_dddd_eeee_ffff";
905 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 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 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 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 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 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 assert_eq!(result, "hello?!");
993 }
994
995 #[test]
996 fn test_fix_length_changing_preserves_positions() {
997 let content = "short___long_text";
998 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 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 let content = "caf\u{00e9} is great";
1043 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 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 use crate::fs::MockFileSystem;
1070
1071 let mock_fs = MockFileSystem::new();
1072 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 use crate::fs::{FileSystem, MockFileSystem};
1103
1104 let mock_fs = Arc::new(MockFileSystem::new());
1105 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 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 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 use crate::fs::{FileSystem, MockFileSystem};
1129
1130 let mock_fs = Arc::new(MockFileSystem::new());
1131 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 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 let content = "caf\u{00e9}";
1161 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}