use crate::diagnostics::{Diagnostic, FIX_CONFIDENCE_MEDIUM_THRESHOLD, Fix, LintResult};
use crate::fs::{FileSystem, RealFileSystem};
use crate::parsers::frontmatter::normalize_line_endings;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FixApplyMode {
SafeOnly,
SafeAndMedium,
All,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FixApplyOptions {
pub dry_run: bool,
pub mode: FixApplyMode,
}
impl FixApplyOptions {
pub fn new(dry_run: bool, mode: FixApplyMode) -> Self {
Self { dry_run, mode }
}
}
#[derive(Debug, Clone)]
pub struct FixResult {
pub path: PathBuf,
pub original: String,
pub fixed: String,
pub applied: Vec<String>,
}
impl FixResult {
pub fn has_changes(&self) -> bool {
self.original != self.fixed
}
}
pub fn apply_fixes(
diagnostics: &[Diagnostic],
dry_run: bool,
safe_only: bool,
) -> LintResult<Vec<FixResult>> {
let mode = if safe_only {
FixApplyMode::SafeOnly
} else {
FixApplyMode::All
};
apply_fixes_with_options(diagnostics, FixApplyOptions::new(dry_run, mode))
}
pub fn apply_fixes_with_fs(
diagnostics: &[Diagnostic],
dry_run: bool,
safe_only: bool,
fs: Option<Arc<dyn FileSystem>>,
) -> LintResult<Vec<FixResult>> {
let mode = if safe_only {
FixApplyMode::SafeOnly
} else {
FixApplyMode::All
};
apply_fixes_with_fs_options(diagnostics, FixApplyOptions::new(dry_run, mode), fs)
}
pub fn apply_fixes_with_options(
diagnostics: &[Diagnostic],
options: FixApplyOptions,
) -> LintResult<Vec<FixResult>> {
apply_fixes_with_fs_options(diagnostics, options, None)
}
pub fn apply_fixes_with_fs_options(
diagnostics: &[Diagnostic],
options: FixApplyOptions,
fs: Option<Arc<dyn FileSystem>>,
) -> LintResult<Vec<FixResult>> {
let fs = fs.unwrap_or_else(|| Arc::new(RealFileSystem));
let mut by_file: HashMap<PathBuf, Vec<&Diagnostic>> = HashMap::new();
for diag in diagnostics {
if diag.has_fixes() {
by_file.entry(diag.file.clone()).or_default().push(diag);
}
}
let mut results = Vec::new();
for (path, file_diagnostics) in by_file {
let raw_content = fs.read_to_string(&path)?;
let original = match normalize_line_endings(&raw_content) {
std::borrow::Cow::Borrowed(_) => raw_content,
std::borrow::Cow::Owned(normalized) => normalized,
};
let mut fixes = select_fixes(&file_diagnostics, options.mode);
if fixes.is_empty() {
continue;
}
fixes.sort_by_key(|f| std::cmp::Reverse(f.start_byte));
let (fixed, applied) = apply_fixes_to_content(&original, &fixes);
if fixed != original {
if !options.dry_run {
fs.write(&path, &fixed)?;
}
results.push(FixResult {
path,
original,
fixed,
applied,
});
}
}
results.sort_by(|a, b| a.path.cmp(&b.path));
Ok(results)
}
fn apply_fixes_to_content(content: &str, fixes: &[&Fix]) -> (String, Vec<String>) {
let mut result = content.to_string();
let mut applied = Vec::new();
let mut last_start = usize::MAX;
let (planned_groups, planned_descriptions) = planned_dependency_keys(content, fixes);
for fix in fixes {
if let Some(depends_on) = fix.depends_on.as_deref() {
let satisfied =
planned_groups.contains(depends_on) || planned_descriptions.contains(depends_on);
if !satisfied {
continue;
}
}
if fix.end_byte < fix.start_byte {
continue;
}
if fix.start_byte > result.len() || fix.end_byte > result.len() {
continue;
}
if !result.is_char_boundary(fix.start_byte) || !result.is_char_boundary(fix.end_byte) {
continue;
}
if fix.end_byte > last_start {
continue;
}
result.replace_range(fix.start_byte..fix.end_byte, &fix.replacement);
applied.push(fix.description.clone());
last_start = fix.start_byte;
}
applied.reverse();
(result, applied)
}
fn select_fixes<'a>(file_diagnostics: &'a [&'a Diagnostic], mode: FixApplyMode) -> Vec<&'a Fix> {
let candidates: Vec<&Fix> = file_diagnostics
.iter()
.flat_map(|d| d.fixes.iter())
.filter(|fix| should_apply_by_mode(fix, mode))
.collect();
let dependency_resolved = resolve_dependency_candidates(candidates);
select_group_alternatives(dependency_resolved)
}
fn should_apply_by_mode(fix: &Fix, mode: FixApplyMode) -> bool {
match mode {
FixApplyMode::SafeOnly => fix.is_safe(),
FixApplyMode::SafeAndMedium => fix.confidence_score() >= FIX_CONFIDENCE_MEDIUM_THRESHOLD,
FixApplyMode::All => true,
}
}
fn resolve_dependency_candidates(mut fixes: Vec<&Fix>) -> Vec<&Fix> {
let deps_count = fixes.iter().filter(|f| f.depends_on.is_some()).count();
let max_iterations = deps_count.saturating_add(1);
for _ in 0..max_iterations {
let groups: HashSet<&str> = fixes.iter().filter_map(|f| f.group.as_deref()).collect();
let descriptions: HashSet<&str> = fixes.iter().map(|f| f.description.as_str()).collect();
let before = fixes.len();
fixes.retain(|fix| match fix.depends_on.as_deref() {
Some(depends_on) => groups.contains(depends_on) || descriptions.contains(depends_on),
None => true,
});
if fixes.len() == before {
break;
}
}
fixes
}
fn select_group_alternatives(fixes: Vec<&Fix>) -> Vec<&Fix> {
let mut selected_groups: HashSet<&str> = HashSet::new();
let mut selected = Vec::new();
for fix in fixes {
if let Some(group) = fix.group.as_deref() {
if selected_groups.contains(group) {
continue;
}
selected_groups.insert(group);
}
selected.push(fix);
}
selected
}
fn planned_dependency_keys<'a>(
content: &str,
fixes: &[&'a Fix],
) -> (HashSet<&'a str>, HashSet<&'a str>) {
let mut groups = HashSet::new();
let mut descriptions = HashSet::new();
let mut last_start = usize::MAX;
for fix in fixes {
if !is_fix_range_applicable(content, fix) {
continue;
}
if fix.end_byte > last_start {
continue;
}
descriptions.insert(fix.description.as_str());
if let Some(group) = fix.group.as_deref() {
groups.insert(group);
}
last_start = fix.start_byte;
}
(groups, descriptions)
}
fn is_fix_range_applicable(content: &str, fix: &Fix) -> bool {
if fix.end_byte < fix.start_byte {
return false;
}
if fix.start_byte > content.len() || fix.end_byte > content.len() {
return false;
}
content.is_char_boundary(fix.start_byte) && content.is_char_boundary(fix.end_byte)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diagnostics::{DiagnosticLevel, Fix};
fn make_diagnostic(path: &str, fixes: Vec<Fix>) -> Diagnostic {
Diagnostic {
level: DiagnosticLevel::Error,
message: "Test error".to_string(),
file: PathBuf::from(path),
line: 1,
column: 1,
rule: "TEST-001".to_string(),
suggestion: None,
fixes,
assumption: None,
metadata: None,
}
}
#[test]
fn test_fix_single_replacement() {
let content = "name: Bad_Name";
let fix = Fix::replace(6, 14, "good-name", "Fix name format", true);
let (result, applied) = apply_fixes_to_content(content, &[&fix]);
assert_eq!(result, "name: good-name");
assert_eq!(applied.len(), 1);
assert_eq!(applied[0], "Fix name format");
}
#[test]
fn test_fix_insertion() {
let content = "hello world";
let fix = Fix::insert(5, " beautiful", "Add word", true);
let (result, _) = apply_fixes_to_content(content, &[&fix]);
assert_eq!(result, "hello beautiful world");
}
#[test]
fn test_fix_deletion() {
let content = "hello beautiful world";
let fix = Fix::delete(5, 15, "Remove word", true);
let (result, _) = apply_fixes_to_content(content, &[&fix]);
assert_eq!(result, "hello world");
}
#[test]
fn test_fix_multiple_non_overlapping() {
let content = "aaa bbb ccc";
let fixes = vec![
Fix::replace(0, 3, "AAA", "Uppercase first", true),
Fix::replace(8, 11, "CCC", "Uppercase last", true),
];
let fix_refs: Vec<&Fix> = fixes.iter().collect();
let mut sorted = fix_refs.clone();
sorted.sort_by_key(|b| std::cmp::Reverse(b.start_byte));
let (result, applied) = apply_fixes_to_content(content, &sorted);
assert_eq!(result, "AAA bbb CCC");
assert_eq!(applied.len(), 2);
}
#[test]
fn test_fix_reverse_order_preserves_positions() {
let content = "foo bar baz";
let fixes = vec![
Fix::replace(0, 3, "FOO", "Fix 1", true),
Fix::replace(8, 11, "BAZ", "Fix 2", true),
];
let mut sorted: Vec<&Fix> = fixes.iter().collect();
sorted.sort_by_key(|b| std::cmp::Reverse(b.start_byte));
let (result, _) = apply_fixes_to_content(content, &sorted);
assert_eq!(result, "FOO bar BAZ");
}
#[test]
fn test_fix_safe_only_filter() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("test.md");
std::fs::write(&path, "name: Bad_Name").unwrap();
let diagnostics = vec![make_diagnostic(
path.to_str().unwrap(),
vec![
Fix::replace(6, 14, "safe-name", "Safe fix", true),
Fix::replace(0, 4, "NAME", "Unsafe fix", false),
],
)];
let results = apply_fixes(&diagnostics, false, true).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].fixed, "name: safe-name");
assert_eq!(results[0].applied.len(), 1);
}
#[test]
fn test_fix_mode_safe_and_medium_filters_low() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("test.md");
std::fs::write(&path, "abcdef").unwrap();
let diagnostics = vec![make_diagnostic(
path.to_str().unwrap(),
vec![
Fix::replace_with_confidence(0, 1, "A", "high", 0.99),
Fix::replace_with_confidence(1, 2, "B", "medium", 0.80),
Fix::replace_with_confidence(2, 3, "C", "low", 0.20),
],
)];
let results = apply_fixes_with_options(
&diagnostics,
FixApplyOptions::new(false, FixApplyMode::SafeAndMedium),
)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].fixed, "ABcdef");
assert_eq!(results[0].applied, vec!["high", "medium"]);
}
#[test]
fn test_fix_mode_safe_only_applies_high_only() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("test.md");
std::fs::write(&path, "abcdef").unwrap();
let diagnostics = vec![make_diagnostic(
path.to_str().unwrap(),
vec![
Fix::replace_with_confidence(0, 1, "A", "high", 0.99),
Fix::replace_with_confidence(1, 2, "B", "medium", 0.80),
Fix::replace_with_confidence(2, 3, "C", "low", 0.20),
],
)];
let results = apply_fixes_with_options(
&diagnostics,
FixApplyOptions::new(false, FixApplyMode::SafeOnly),
)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].fixed, "Abcdef");
assert_eq!(results[0].applied, vec!["high"]);
}
#[test]
fn test_fix_group_applies_first_alternative_only() {
let content = "hello";
let first = Fix::insert(5, "!", "first", true).with_group("punctuation");
let second = Fix::insert(5, "?", "second", true).with_group("punctuation");
let fixes = vec![&first, &second];
let (fixed, applied) = apply_fixes_to_content(content, &fixes);
let diagnostic = make_diagnostic(
"x.md",
vec![
Fix::insert(5, "!", "first", true).with_group("punctuation"),
Fix::insert(5, "?", "second", true).with_group("punctuation"),
],
);
let diagnostics = [&diagnostic];
let selected = select_fixes(&diagnostics, FixApplyMode::All);
let mut refs = selected;
refs.sort_by_key(|b| std::cmp::Reverse(b.start_byte));
let (selected_fixed, selected_applied) = apply_fixes_to_content(content, &refs);
assert_eq!(fixed, "hello?!");
assert_eq!(applied.len(), 2);
assert_eq!(selected_fixed, "hello!");
assert_eq!(selected_applied, vec!["first"]);
}
#[test]
fn test_fix_dependency_requires_predecessor() {
let content = "foo bar";
let prerequisite = Fix::replace(4, 7, "BAR", "normalize-tail", true).with_group("step1");
let dependent = Fix::replace(0, 3, "FOO", "normalize-head", true).with_dependency("step1");
let orphan = Fix::replace(0, 3, "XXX", "orphan", true).with_dependency("missing");
let mut refs = vec![&prerequisite, &dependent];
refs.sort_by_key(|b| std::cmp::Reverse(b.start_byte));
let (fixed, applied) = apply_fixes_to_content(content, &refs);
assert_eq!(fixed, "FOO BAR");
assert_eq!(applied, vec!["normalize-head", "normalize-tail"]);
let mut orphan_refs = vec![&orphan];
orphan_refs.sort_by_key(|b| std::cmp::Reverse(b.start_byte));
let (orphan_fixed, orphan_applied) = apply_fixes_to_content(content, &orphan_refs);
assert_eq!(orphan_fixed, content);
assert!(orphan_applied.is_empty());
}
#[test]
fn test_fix_dependency_not_order_sensitive() {
let content = "foo bar";
let prerequisite = Fix::replace(0, 3, "FOO", "normalize-head", true).with_group("step1");
let dependent = Fix::replace(4, 7, "BAR", "normalize-tail", true).with_dependency("step1");
let mut refs = vec![&prerequisite, &dependent];
refs.sort_by_key(|b| std::cmp::Reverse(b.start_byte));
let (fixed, applied) = apply_fixes_to_content(content, &refs);
assert_eq!(fixed, "FOO BAR");
assert_eq!(applied, vec!["normalize-head", "normalize-tail"]);
}
#[test]
fn test_fix_dependency_can_reference_description() {
let content = "foo bar";
let prerequisite = Fix::replace(0, 3, "FOO", "normalize-head", true);
let dependent =
Fix::replace(4, 7, "BAR", "normalize-tail", true).with_dependency("normalize-head");
let mut refs = vec![&prerequisite, &dependent];
refs.sort_by_key(|b| std::cmp::Reverse(b.start_byte));
let (fixed, applied) = apply_fixes_to_content(content, &refs);
assert_eq!(fixed, "FOO BAR");
assert_eq!(applied, vec!["normalize-head", "normalize-tail"]);
}
#[test]
fn test_fix_dependency_skips_when_prerequisite_would_be_invalid() {
let content = "foo bar";
let prerequisite =
Fix::replace(10, 11, "X", "invalid-prerequisite", true).with_group("step1");
let dependent = Fix::replace(4, 7, "BAR", "normalize-tail", true).with_dependency("step1");
let mut refs = vec![&prerequisite, &dependent];
refs.sort_by_key(|b| std::cmp::Reverse(b.start_byte));
let (fixed, applied) = apply_fixes_to_content(content, &refs);
assert_eq!(fixed, content);
assert!(applied.is_empty());
}
#[test]
fn test_fix_group_falls_back_when_first_candidate_is_removed() {
let diagnostic = make_diagnostic(
"x.md",
vec![
Fix::insert(5, "!", "first", true)
.with_group("punctuation")
.with_dependency("missing"),
Fix::insert(5, "?", "second", true).with_group("punctuation"),
],
);
let diagnostics = [&diagnostic];
let selected = select_fixes(&diagnostics, FixApplyMode::All);
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].description, "second");
}
#[test]
fn test_fix_dry_run_no_write() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("test.md");
let original = "name: Bad_Name";
std::fs::write(&path, original).unwrap();
let diagnostics = vec![make_diagnostic(
path.to_str().unwrap(),
vec![Fix::replace(6, 14, "good-name", "Fix name", true)],
)];
let results = apply_fixes(&diagnostics, true, false).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].fixed, "name: good-name");
let file_content = std::fs::read_to_string(&path).unwrap();
assert_eq!(file_content, original);
}
#[test]
fn test_fix_actual_write() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("test.md");
std::fs::write(&path, "name: Bad_Name").unwrap();
let diagnostics = vec![make_diagnostic(
path.to_str().unwrap(),
vec![Fix::replace(6, 14, "good-name", "Fix name", true)],
)];
let results = apply_fixes(&diagnostics, false, false).unwrap();
assert_eq!(results.len(), 1);
let file_content = std::fs::read_to_string(&path).unwrap();
assert_eq!(file_content, "name: good-name");
}
#[test]
fn test_fix_invalid_positions_skipped() {
let content = "short";
let fix = Fix::replace(100, 200, "won't apply", "Bad fix", true);
let (result, applied) = apply_fixes_to_content(content, &[&fix]);
assert_eq!(result, "short");
assert!(applied.is_empty());
}
#[test]
fn test_fix_empty_diagnostics() {
let results = apply_fixes(&[], false, false).unwrap();
assert!(results.is_empty());
}
#[test]
fn test_fix_no_fixes_in_diagnostics() {
let diagnostics = vec![Diagnostic {
level: DiagnosticLevel::Error,
message: "No fix available".to_string(),
file: PathBuf::from("test.md"),
line: 1,
column: 1,
rule: "TEST-001".to_string(),
suggestion: None,
fixes: Vec::new(),
assumption: None,
metadata: None,
}];
let results = apply_fixes(&diagnostics, false, false).unwrap();
assert!(results.is_empty());
}
#[test]
fn test_fix_result_has_changes() {
let result_with_changes = FixResult {
path: PathBuf::from("test.md"),
original: "old".to_string(),
fixed: "new".to_string(),
applied: vec!["Fix".to_string()],
};
assert!(result_with_changes.has_changes());
let result_no_changes = FixResult {
path: PathBuf::from("test.md"),
original: "same".to_string(),
fixed: "same".to_string(),
applied: vec![],
};
assert!(!result_no_changes.has_changes());
}
#[test]
fn test_fix_overlapping_skipped() {
let content = "hello world";
let fixes = vec![
Fix::replace(6, 11, "universe", "Fix 1", true),
Fix::replace(4, 8, "XXX", "Fix 2 overlaps", true),
];
let mut sorted: Vec<&Fix> = fixes.iter().collect();
sorted.sort_by_key(|b| std::cmp::Reverse(b.start_byte));
let (result, applied) = apply_fixes_to_content(content, &sorted);
assert_eq!(result, "hello universe");
assert_eq!(applied.len(), 1);
assert_eq!(applied[0], "Fix 1");
}
#[test]
fn test_apply_fixes_with_mock_fs_dry_run() {
use crate::fs::MockFileSystem;
let mock_fs = MockFileSystem::new();
mock_fs.add_file("/project/test.md", "name: Bad_Name");
let diagnostics = vec![make_diagnostic(
"/project/test.md",
vec![Fix::replace(6, 14, "good-name", "Fix name", true)],
)];
let results =
apply_fixes_with_fs(&diagnostics, true, false, Some(Arc::new(mock_fs))).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].original, "name: Bad_Name");
assert_eq!(results[0].fixed, "name: good-name");
assert!(results[0].has_changes());
}
#[test]
fn test_apply_fixes_with_mock_fs_actual_write() {
use crate::fs::{FileSystem, MockFileSystem};
let mock_fs = Arc::new(MockFileSystem::new());
mock_fs.add_file("/project/test.md", "name: Bad_Name");
let diagnostics = vec![make_diagnostic(
"/project/test.md",
vec![Fix::replace(6, 14, "good-name", "Fix name", true)],
)];
let fs_clone: Arc<dyn FileSystem> = Arc::clone(&mock_fs) as Arc<dyn FileSystem>;
let results = apply_fixes_with_fs(&diagnostics, false, false, Some(fs_clone)).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].fixed, "name: good-name");
let content = mock_fs
.read_to_string(std::path::Path::new("/project/test.md"))
.unwrap();
assert_eq!(content, "name: good-name");
}
#[test]
fn test_apply_fixes_with_mock_fs_safe_only() {
use crate::fs::{FileSystem, MockFileSystem};
let mock_fs = Arc::new(MockFileSystem::new());
mock_fs.add_file("/project/test.md", "name: Bad_Name");
let diagnostics = vec![make_diagnostic(
"/project/test.md",
vec![
Fix::replace(6, 14, "safe-name", "Safe fix", true),
Fix::replace(0, 4, "NAME", "Unsafe fix", false),
],
)];
let fs_clone: Arc<dyn FileSystem> = Arc::clone(&mock_fs) as Arc<dyn FileSystem>;
let results = apply_fixes_with_fs(&diagnostics, false, true, Some(fs_clone)).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].fixed, "name: safe-name");
assert_eq!(results[0].applied.len(), 1);
assert_eq!(results[0].applied[0], "Safe fix");
let content = mock_fs
.read_to_string(std::path::Path::new("/project/test.md"))
.unwrap();
assert_eq!(content, "name: safe-name");
}
#[test]
fn test_apply_fixes_with_mock_fs_multiple_files() {
use crate::fs::{FileSystem, MockFileSystem};
let mock_fs = Arc::new(MockFileSystem::new());
mock_fs.add_file("/project/file1.md", "old1");
mock_fs.add_file("/project/file2.md", "old2");
let diagnostics = vec![
make_diagnostic(
"/project/file1.md",
vec![Fix::replace(0, 4, "new1", "Fix file1", true)],
),
make_diagnostic(
"/project/file2.md",
vec![Fix::replace(0, 4, "new2", "Fix file2", true)],
),
];
let fs_clone: Arc<dyn FileSystem> = Arc::clone(&mock_fs) as Arc<dyn FileSystem>;
let results = apply_fixes_with_fs(&diagnostics, false, false, Some(fs_clone)).unwrap();
assert_eq!(results.len(), 2);
let content1 = mock_fs
.read_to_string(std::path::Path::new("/project/file1.md"))
.unwrap();
let content2 = mock_fs
.read_to_string(std::path::Path::new("/project/file2.md"))
.unwrap();
assert_eq!(content1, "new1");
assert_eq!(content2, "new2");
}
#[test]
fn test_apply_fixes_with_mock_fs_file_not_found() {
use crate::fs::MockFileSystem;
let mock_fs = MockFileSystem::new();
let diagnostics = vec![make_diagnostic(
"/project/nonexistent.md",
vec![Fix::replace(0, 4, "new", "Fix", true)],
)];
let result = apply_fixes_with_fs(&diagnostics, false, false, Some(Arc::new(mock_fs)));
assert!(result.is_err());
}
#[test]
fn test_apply_fixes_with_mock_fs_no_changes() {
use crate::fs::MockFileSystem;
let mock_fs = MockFileSystem::new();
mock_fs.add_file("/project/test.md", "unchanged");
let diagnostics = vec![Diagnostic {
level: DiagnosticLevel::Error,
message: "No fix available".to_string(),
file: PathBuf::from("/project/test.md"),
line: 1,
column: 1,
rule: "TEST-001".to_string(),
suggestion: None,
fixes: Vec::new(),
assumption: None,
metadata: None,
}];
let results =
apply_fixes_with_fs(&diagnostics, false, false, Some(Arc::new(mock_fs))).unwrap();
assert!(results.is_empty());
}
#[test]
fn test_fix_three_non_overlapping_descending() {
let content = "aaaa_bbbb_cccc_dddd_eeee_ffff";
let fixes = vec![
Fix::replace(20, 24, "EEEE", "Fix third", true),
Fix::replace(10, 14, "CCCC", "Fix second", true),
Fix::replace(0, 4, "AAAA", "Fix first", true),
];
let fix_refs: Vec<&Fix> = fixes.iter().collect();
let (result, applied) = apply_fixes_to_content(content, &fix_refs);
assert_eq!(applied.len(), 3);
assert!(result.contains("AAAA"));
assert!(result.contains("CCCC"));
assert!(result.contains("EEEE"));
}
#[test]
fn test_fix_overlapping_middle_skipped() {
let content = "0123456789abcdef";
let fixes = vec![
Fix::replace(10, 14, "XX", "Fix at 10", true),
Fix::replace(6, 12, "YY", "Fix at 6 (overlaps)", true),
Fix::replace(0, 4, "ZZ", "Fix at 0", true),
];
let fix_refs: Vec<&Fix> = fixes.iter().collect();
let (result, applied) = apply_fixes_to_content(content, &fix_refs);
assert_eq!(
applied.len(),
2,
"Only two non-overlapping fixes should apply"
);
assert!(result.contains("XX"), "Fix at 10-14 should be applied");
assert!(result.contains("ZZ"), "Fix at 0-4 should be applied");
assert!(
!result.contains("YY"),
"Overlapping fix at 6-12 should be skipped"
);
}
#[test]
fn test_fix_adjacent_fixes() {
let content = "hello world";
let fixes = vec![
Fix::replace(5, 6, "_", "Replace space", true),
Fix::replace(0, 5, "HELLO", "Uppercase first word", true),
];
let fix_refs: Vec<&Fix> = fixes.iter().collect();
let (result, applied) = apply_fixes_to_content(content, &fix_refs);
assert_eq!(applied.len(), 2, "Adjacent fixes should both apply");
assert_eq!(result, "HELLO_world");
}
#[test]
fn test_fix_same_position_insertions() {
let content = "hello";
let fixes = vec![
Fix::insert(5, "!", "Add exclamation", true),
Fix::insert(5, "?", "Add question", true),
];
let mut fix_refs: Vec<&Fix> = fixes.iter().collect();
fix_refs.sort_by_key(|b| std::cmp::Reverse(b.start_byte));
let (result, applied) = apply_fixes_to_content(content, &fix_refs);
assert_eq!(
applied.len(),
2,
"Same-position insertions should both apply"
);
assert_eq!(result, "hello?!");
}
#[test]
fn test_fix_length_changing_preserves_positions() {
let content = "short___long_text";
let fixes = vec![
Fix::replace(12, 17, "replacement_that_is_longer", "Fix second", true),
Fix::replace(0, 5, "S", "Fix first", true),
];
let fix_refs: Vec<&Fix> = fixes.iter().collect();
let (result, applied) = apply_fixes_to_content(content, &fix_refs);
assert_eq!(applied.len(), 2, "Both fixes should apply");
assert!(
result.starts_with("S"),
"First 5 chars should be replaced with 'S'"
);
assert!(
result.contains("replacement_that_is_longer"),
"Later range should be replaced"
);
}
#[test]
fn test_fix_end_before_start_skipped() {
let content = "hello";
let fix = Fix {
start_byte: 3,
end_byte: 1,
replacement: "X".to_string(),
description: "Invalid fix".to_string(),
safe: true,
confidence: Some(1.0),
group: None,
depends_on: None,
};
let (result, applied) = apply_fixes_to_content(content, &[&fix]);
assert_eq!(result, "hello", "Content should be unchanged");
assert!(applied.is_empty(), "Invalid fix should be skipped");
}
#[test]
fn test_fix_unicode_content_boundaries() {
let content = "caf\u{00e9} is great";
let fix = Fix::replace(3, 5, "e", "Normalize accent", true);
let (result, applied) = apply_fixes_to_content(content, &[&fix]);
assert_eq!(result, "cafe is great");
assert_eq!(applied.len(), 1);
}
#[test]
fn test_fix_crlf_content() {
let content = "hello\r\nworld";
let fix = Fix::replace(5, 7, "\n", "Normalize line ending", true);
let (result, applied) = apply_fixes_to_content(content, &[&fix]);
assert_eq!(result, "hello\nworld");
assert_eq!(applied.len(), 1);
}
#[test]
fn test_apply_fixes_with_mock_fs_crlf_normalization() {
use crate::fs::MockFileSystem;
let mock_fs = MockFileSystem::new();
mock_fs.add_file("/project/skill.md", "name:\r\n bad-name");
let diagnostics = vec![make_diagnostic(
"/project/skill.md",
vec![Fix::replace(7, 15, "good-name", "Fix name", true)],
)];
let results =
apply_fixes_with_fs(&diagnostics, true, false, Some(Arc::new(mock_fs))).unwrap();
assert_eq!(results.len(), 1, "Should produce one FixResult");
assert!(
!results[0].original.contains('\r'),
"FixResult.original should be LF-normalized (no \\r)"
);
assert_eq!(results[0].original, "name:\n bad-name");
assert_eq!(results[0].fixed, "name:\n good-name");
assert!(results[0].has_changes());
}
#[test]
fn test_apply_fixes_with_mock_fs_crlf_no_actual_changes() {
use crate::fs::{FileSystem, MockFileSystem};
let mock_fs = Arc::new(MockFileSystem::new());
mock_fs.add_file("/project/skill.md", "name:\r\n good-name");
let fs_clone: Arc<dyn FileSystem> = Arc::clone(&mock_fs) as Arc<dyn FileSystem>;
let diagnostics = vec![make_diagnostic(
"/project/skill.md",
vec![Fix::replace(7, 16, "good-name", "No-op fix", true)],
)];
let results = apply_fixes_with_fs(&diagnostics, false, false, Some(fs_clone)).unwrap();
assert!(
results.is_empty(),
"No FixResult should be emitted when fix produces no net change on normalized content"
);
}
#[test]
fn test_apply_fixes_with_mock_fs_crlf_actual_write() {
use crate::fs::{FileSystem, MockFileSystem};
let mock_fs = Arc::new(MockFileSystem::new());
mock_fs.add_file("/project/skill.md", "name:\r\n bad-name");
let fs_clone: Arc<dyn FileSystem> = Arc::clone(&mock_fs) as Arc<dyn FileSystem>;
let diagnostics = vec![make_diagnostic(
"/project/skill.md",
vec![Fix::replace(7, 15, "good-name", "Fix name", true)],
)];
let results = apply_fixes_with_fs(&diagnostics, false, false, Some(fs_clone)).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].fixed, "name:\n good-name");
let written = mock_fs
.read_to_string(std::path::Path::new("/project/skill.md"))
.unwrap();
assert!(
!written.contains('\r'),
"Written file should have no \\r (LF-normalized)"
);
assert_eq!(written, "name:\n good-name");
}
#[test]
fn test_fix_utf8_boundary_skip() {
let content = "caf\u{00e9}";
let fix = Fix::replace(4, 5, "X", "Mid-codepoint fix", true);
let (result, applied) = apply_fixes_to_content(content, &[&fix]);
assert_eq!(
result, "caf\u{00e9}",
"Content should be unchanged when fix targets mid-codepoint"
);
assert!(
applied.is_empty(),
"Fix at non-char-boundary should be skipped"
);
}
#[test]
fn test_fix_replacement_with_unicode() {
let content = "hello world";
let fix = Fix::replace(6, 11, "\u{4e16}\u{754c}", "Replace with CJK", true);
let (result, applied) = apply_fixes_to_content(content, &[&fix]);
assert_eq!(result, "hello \u{4e16}\u{754c}");
assert_eq!(applied.len(), 1);
}
}