aico/diffing/
patching.rs

1pub fn create_patched_content(
2    original_content: &str,
3    search_block: &str,
4    replace_block: &str,
5) -> Option<String> {
6    // Stage 1: Exact match
7    if let Some(patched) = try_exact_string_patch(original_content, search_block, replace_block) {
8        return Some(patched);
9    }
10
11    // Stage 2: Whitespace-flexible match
12    try_whitespace_flexible_patch(original_content, search_block, replace_block)
13}
14
15fn try_exact_string_patch(original: &str, search: &str, replace: &str) -> Option<String> {
16    // Handle file creation
17    if search.is_empty() && original.is_empty() {
18        return Some(replace.to_string());
19    }
20
21    // Handle file deletion
22    if replace.is_empty() && search == original {
23        return Some(String::new());
24    }
25
26    if search.trim().is_empty() {
27        return None;
28    }
29
30    original
31        .find(search)
32        .map(|_| original.replacen(search, replace, 1))
33}
34
35fn try_whitespace_flexible_patch(original: &str, search: &str, replace: &str) -> Option<String> {
36    // Port of python lines logic: split_inclusive keeps newlines.
37    // We normalize to \n conceptually by using .trim_end_matches(['\n', '\r'])
38    let original_lines: Vec<&str> = original.split_inclusive('\n').collect();
39    let search_lines: Vec<&str> = search.split_inclusive('\n').collect();
40    let replace_lines: Vec<&str> = replace.split_inclusive('\n').collect();
41
42    if search_lines.is_empty() || search_lines.len() > original_lines.len() {
43        return None;
44    }
45
46    let stripped_search: Vec<&str> = search_lines
47        .iter()
48        .map(|s| s.trim_end_matches(['\n', '\r']).trim())
49        .collect();
50    if stripped_search.iter().all(|s| s.is_empty()) {
51        return None;
52    }
53
54    // Find match index
55    let match_start_index = original_lines
56        .windows(search_lines.len())
57        .position(|window| {
58            window
59                .iter()
60                // Inline the normalization logic here to avoid lifetime errors
61                .map(|s| s.trim_end_matches(['\n', '\r']).trim())
62                .eq(stripped_search.iter().cloned())
63        });
64
65    let start_idx = match_start_index?;
66
67    // Calculate indentation
68    let matched_chunk = &original_lines[start_idx..start_idx + search_lines.len()];
69    let original_indent = get_consistent_indentation(matched_chunk);
70    let replace_indent = get_consistent_indentation(&replace_lines);
71
72    let mut new_lines: Vec<String> = Vec::new();
73
74    // Pre-match
75    new_lines.extend(original_lines[..start_idx].iter().map(|s| s.to_string()));
76
77    // Replaced block
78    for line in replace_lines {
79        // Carry over the line content including its original trailing whitespace/newline
80        // because original_lines and replace_lines were split_inclusive.
81        if line.trim().is_empty() {
82            new_lines.push(line.to_string());
83            continue;
84        }
85
86        let relative = if !replace_indent.is_empty() && line.starts_with(&replace_indent) {
87            &line[replace_indent.len()..]
88        } else {
89            line
90        };
91        new_lines.push(format!("{}{}", original_indent, relative));
92    }
93
94    // Post-match
95    new_lines.extend(
96        original_lines[start_idx + search_lines.len()..]
97            .iter()
98            .map(|s| s.to_string()),
99    );
100
101    Some(new_lines.concat())
102}
103
104fn get_consistent_indentation(lines: &[&str]) -> String {
105    let mut iter = lines.iter().filter(|l| !l.trim().is_empty()).cloned();
106
107    let first = match iter.next() {
108        Some(f) => f,
109        None => return String::new(),
110    };
111
112    // Start with the first line's indentation as the candidate
113    let indent_len = first.len() - first.trim_start().len();
114    let mut common_indent = &first[..indent_len];
115
116    for line in iter {
117        let shared_len = common_indent
118            .char_indices()
119            .zip(line.chars())
120            .take_while(|((_, c1), c2)| c1 == c2)
121            .map(|((i, c), _)| i + c.len_utf8())
122            .last()
123            .unwrap_or(0);
124
125        common_indent = &common_indent[..shared_len];
126
127        if common_indent.is_empty() {
128            break;
129        }
130    }
131
132    common_indent.to_string()
133}