1use harness_core::{ToolError, ToolErrorCode};
2
3use crate::format::{format_fuzzy_candidates, format_match_locations};
4use crate::matching::{
5 build_match_locations, find_all_occurrences, find_fuzzy_candidates,
6 substring_boundary_collisions, FuzzyOpts,
7};
8use crate::normalize::normalize_line_endings;
9use crate::schema::EditSpec;
10
11pub struct ApplyResult {
12 pub content: String,
13 pub replacements: usize,
14 pub warnings: Vec<String>,
15}
16
17pub enum PipelineResult {
18 Ok {
19 content: String,
20 total_replacements: usize,
21 warnings: Vec<String>,
22 },
23 Err {
24 error: ToolError,
25 index: usize,
26 },
27}
28
29fn strip_line_whitespace(s: &str) -> String {
34 s.split('\n').map(|l| l.trim()).collect::<Vec<_>>().join("\n")
35}
36
37pub fn apply_edit(content: &str, edit: &EditSpec) -> Result<ApplyResult, ToolError> {
40 let old_raw = &edit.old_string;
41 let new_raw = &edit.new_string;
42 let ignore_ws = edit.ignore_whitespace.unwrap_or(false);
43
44 if old_raw == new_raw {
45 return Err(ToolError::new(
46 ToolErrorCode::NoOpEdit,
47 "old_string equals new_string (no-op edit). If you intended to verify file state, use Read instead.",
48 ));
49 }
50
51 let norm_content = normalize_line_endings(content);
52 let norm_old = normalize_line_endings(old_raw);
53 let norm_new = normalize_line_endings(new_raw);
54
55 if norm_content.is_empty() {
56 return Err(ToolError::new(
57 ToolErrorCode::EmptyFile,
58 "Edit cannot anchor to an empty file. Use Write to create initial content; Edit requires existing text as an anchor.",
59 ));
60 }
61
62 let (search_content, search_needle) = if ignore_ws {
65 (strip_line_whitespace(&norm_content), strip_line_whitespace(&norm_old))
66 } else {
67 (norm_content.clone(), norm_old.clone())
68 };
69
70 let offsets = find_all_occurrences(&search_content, &search_needle);
71
72 if offsets.is_empty() {
73 let candidates = find_fuzzy_candidates(&norm_content, &norm_old, FuzzyOpts::default());
74 let block = format_fuzzy_candidates(&candidates);
75 let msg = if !block.is_empty() {
76 format!(
77 "old_string was not found in the file.\n\nClosest candidates:\n\n{}\n\nIf one of these is the intended location, re-emit Edit with old_string taken verbatim from the candidate block above. Otherwise, re-Read the file to confirm the expected text is present.",
78 block
79 )
80 } else {
81 "old_string was not found in the file, and no fuzzy candidates crossed the similarity threshold. Re-Read the file to confirm the expected text is present.".to_string()
82 };
83 return Err(ToolError::new(ToolErrorCode::OldStringNotFound, msg).with_meta(
84 serde_json::json!({
85 "candidates": candidates,
86 }),
87 ));
88 }
89
90 let replace_all = edit.replace_all.unwrap_or(false);
91 if offsets.len() > 1 && !replace_all {
92 let locations = build_match_locations(&search_content, &search_needle, &offsets);
93 let block = format_match_locations(&locations);
94 let msg = format!(
95 "old_string matches {} locations; edit requires exactly one match.\n\n{}\n\nWiden old_string with surrounding context so it matches exactly one location, or pass replace_all: true if you intend to replace every occurrence.",
96 offsets.len(),
97 block
98 );
99 return Err(ToolError::new(ToolErrorCode::OldStringNotUnique, msg).with_meta(
100 serde_json::json!({
101 "match_count": offsets.len(),
102 "locations": locations,
103 }),
104 ));
105 }
106
107 let target_offsets: Vec<usize> = if replace_all {
108 offsets.clone()
109 } else {
110 vec![offsets[0]]
111 };
112
113 let mut warnings: Vec<String> = Vec::new();
114 if replace_all && offsets.len() > 1 && !ignore_ws {
115 let flagged = substring_boundary_collisions(&norm_content, &norm_old, &target_offsets);
116 if !flagged.is_empty() {
117 let lines_str = flagged
118 .iter()
119 .map(|n| n.to_string())
120 .collect::<Vec<_>>()
121 .join(", ");
122 let needle_preview = truncate_for_warning(&norm_old);
123 warnings.push(format!(
124 "replace_all pattern \"{}\" is adjacent to identifier characters at line(s) {}; verify these replacements did not land inside a larger identifier.",
125 needle_preview, lines_str
126 ));
127 }
128 }
129
130 let new_content = if ignore_ws {
134 replace_by_line_range(&norm_content, &search_content, &search_needle, &norm_new, &target_offsets)
135 } else {
136 replace_at_offsets(&norm_content, &norm_old, &norm_new, &target_offsets)
137 };
138
139 Ok(ApplyResult {
140 content: new_content,
141 replacements: target_offsets.len(),
142 warnings,
143 })
144}
145
146pub fn apply_pipeline(initial: &str, edits: &[EditSpec]) -> PipelineResult {
147 let mut content = initial.to_string();
148 let mut total = 0usize;
149 let mut warnings: Vec<String> = Vec::new();
150
151 for (i, edit) in edits.iter().enumerate() {
152 match apply_edit(&content, edit) {
153 Ok(r) => {
154 content = r.content;
155 total += r.replacements;
156 for w in r.warnings {
157 warnings.push(format!("edit[{}]: {}", i, w));
158 }
159 }
160 Err(e) => {
161 let msg = format!("edit[{}]: {}", i, e.message);
162 let mut meta = e.meta.clone().unwrap_or_else(|| serde_json::json!({}));
163 meta["edit_index"] = serde_json::json!(i);
164 let new_err = ToolError::new(e.code, msg).with_meta(meta);
165 return PipelineResult::Err {
166 error: new_err,
167 index: i,
168 };
169 }
170 }
171 }
172
173 PipelineResult::Ok {
174 content,
175 total_replacements: total,
176 warnings,
177 }
178}
179
180fn replace_at_offsets(
181 haystack: &str,
182 needle: &str,
183 replacement: &str,
184 offsets: &[usize],
185) -> String {
186 if offsets.is_empty() {
187 return haystack.to_string();
188 }
189 let needle_len = needle.as_bytes().len();
190 let mut out = String::with_capacity(haystack.len());
191 let mut cursor = 0usize;
192 for &off in offsets {
193 out.push_str(&haystack[cursor..off]);
194 out.push_str(replacement);
195 cursor = off + needle_len;
196 }
197 out.push_str(&haystack[cursor..]);
198 out
199}
200
201fn replace_by_line_range(
206 original: &str,
207 stripped: &str,
208 old_stripped: &str,
209 replacement: &str,
210 stripped_offsets: &[usize],
211) -> String {
212 let orig_lines: Vec<&str> = original.split('\n').collect();
213 let strip_lines: Vec<&str> = stripped.split('\n').collect();
214
215 let old_line_count = old_stripped.split('\n').count();
217 let replace_lines: Vec<&str> = replacement.split('\n').collect();
218
219 let single_line = old_line_count == 1 && replace_lines.len() == 1;
222
223 let mut positions: Vec<(usize, usize)> = Vec::new();
225 for &off in stripped_offsets {
226 let mut line_idx = 0usize;
227 let mut byte_pos = 0usize;
228 let mut found = false;
229 for (i, line) in strip_lines.iter().enumerate() {
230 if byte_pos <= off && off < byte_pos + line.len() {
231 line_idx = i;
232 found = true;
233 break;
234 }
235 byte_pos += line.len() + 1; }
237 if !found && byte_pos <= off && off < byte_pos + strip_lines.get(line_idx).map(|l| l.len()).unwrap_or(0) {
240 found = true;
241 }
242 if !found {
243 line_idx = (line_idx).min(strip_lines.len().saturating_sub(1));
245 byte_pos = if line_idx == 0 { 0 } else {
246 strip_lines[..line_idx].iter().map(|l| l.len() + 1).sum()
247 };
248 }
249 let byte_pos = byte_pos.min(off);
250 let offset_in_line = off - byte_pos;
251 positions.push((line_idx, offset_in_line));
252 }
253
254 let mut result_lines: Vec<String> = orig_lines.iter().map(|l| l.to_string()).collect();
255
256 if single_line {
257 for (line_idx, stripped_off) in positions.iter().rev() {
261 if let Some(line) = result_lines.get_mut(*line_idx) {
262 let orig_line = line.as_str();
263 let lead_ws = orig_line.len() - orig_line.trim_start().len();
265
266 let orig_start = lead_ws + stripped_off;
270 let orig_end = orig_start + old_stripped.len();
271
272 if orig_start <= orig_line.len() && orig_end <= orig_line.len() {
273 let new_line = format!(
274 "{}{}{}",
275 &orig_line[..orig_start],
276 replacement,
277 &orig_line[orig_end..]
278 );
279 *line = new_line;
280 } else {
281 *line = replacement.to_string();
283 }
284 }
285 }
286 } else {
287 let needle_lines: Vec<&str> = old_stripped.split('\n').collect();
291 let first_trimmed = needle_lines.first().map(|l| l.trim()).unwrap_or("");
292 let last_trimmed = needle_lines.last().map(|l| l.trim()).unwrap_or("");
293 let mut ranges: Vec<(usize, usize)> = Vec::new();
294 for (line_idx, _offset) in &positions {
295 ranges.push((*line_idx, *line_idx + old_line_count));
296 }
297 for (start, end) in ranges.into_iter().rev() {
298 let clamped_start = start.min(result_lines.len());
299 let clamped_end = end.min(result_lines.len());
300
301 let prefix = if let Some(first_line) = result_lines.get(clamped_start) {
303 let match_pos = first_line.find(first_trimmed);
304 if let Some(pos) = match_pos {
305 first_line[..pos].to_string()
306 } else {
307 String::new()
308 }
309 } else {
310 String::new()
311 };
312
313 let suffix = if let Some(last_line) = result_lines.get(clamped_end - 1) {
315 let match_pos = last_line.find(last_trimmed);
316 if let Some(pos) = match_pos {
317 let end_pos = pos + last_trimmed.len();
318 if end_pos <= last_line.len() {
319 last_line[end_pos..].to_string()
320 } else {
321 String::new()
322 }
323 } else {
324 String::new()
325 }
326 } else {
327 String::new()
328 };
329
330 let mut new_lines: Vec<String> = replace_lines.iter().map(|l| l.to_string()).collect();
331
332 if !prefix.is_empty() {
334 if let Some(first) = new_lines.first_mut() {
335 *first = format!("{}{}", prefix, first);
336 }
337 }
338
339 if !suffix.is_empty() {
341 if let Some(last) = new_lines.last_mut() {
342 *last = format!("{}{}", last, suffix);
343 }
344 }
345
346 result_lines.splice(clamped_start..clamped_end, new_lines);
347 }
348 }
349
350 result_lines.join("\n")
351}
352
353fn truncate_for_warning(s: &str) -> String {
354 let one_line: String = s.chars().map(|c| if c == '\n' { ' ' } else { c }).collect();
355 if one_line.chars().count() <= 40 {
356 one_line
357 } else {
358 let mut out: String = one_line.chars().take(37).collect();
359 out.push_str("...");
360 out
361 }
362}