Skip to main content

harness_write/
engine.rs

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
29/// Strip leading and trailing whitespace from each line, preserving line
30/// count and internal spacing. Uses split('\n') to keep the trailing empty
31/// line when the string ends with \n, so "foo\n" stays as two lines rather
32/// than collapsing to one (which would match "foo_extra" incorrectly).
33fn strip_line_whitespace(s: &str) -> String {
34    s.split('\n').map(|l| l.trim()).collect::<Vec<_>>().join("\n")
35}
36
37/// Apply a single edit spec to `content`. Returns either the new content +
38/// counts, or a structured ToolError.
39pub 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    // When ignore_whitespace is true, match against whitespace-stripped
63    // versions but replace in the original text.
64    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    // When ignore_whitespace is true, the offsets are from the stripped
131    // content. Map them back to line ranges in the original content and
132    // replace those lines.
133    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
201/// Replace text by mapping byte offsets from a whitespace-stripped version
202/// back to the original content. For single-line matches, replaces the
203/// matched substring within the line (preserving surrounding text). For
204/// multi-line matches, replaces the entire line range.
205fn 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    // Number of lines the old_string spans.
216    let old_line_count = old_stripped.split('\n').count();
217    let replace_lines: Vec<&str> = replacement.split('\n').collect();
218
219    // Check if this is a single-line match — if so, do substring replacement
220    // to preserve surrounding text on the line.
221    let single_line = old_line_count == 1 && replace_lines.len() == 1;
222
223    // Build (line_idx, byte_offset_in_stripped_line) for each match.
224    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; // +1 for the '\n'
236        }
237        // If offset lands on a newline boundary (empty stripped line),
238        // advance to the next line so we don't silently fall back to line 0.
239        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            // Offset past all lines — clamp to last line to avoid underflow.
244            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        // Single-line: replace the matched span within each original line,
258        // preserving surrounding text. Map the byte offset from the stripped
259        // line back to the original line to find the correct position.
260        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                // Find leading whitespace length in the original line.
264                let lead_ws = orig_line.len() - orig_line.trim_start().len();
265
266                // The match starts at `stripped_off` bytes into the stripped
267                // line (which is the original line with leading/trailing
268                // whitespace removed). Map to the original line:
269                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                    // Offset mapping failed — fall back to full line replace.
282                    *line = replacement.to_string();
283                }
284            }
285        }
286    } else {
287        // Multi-line: replace matched range while preserving surrounding text
288        // on the first and last lines. For "prefix old1\nold2 suffix" matching
289        // "old1\nold2", this preserves "prefix " and " suffix".
290        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            // Preserve prefix on the first line.
302            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            // Preserve suffix on the last line.
314            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            // Prepend prefix to the first replacement line.
333            if !prefix.is_empty() {
334                if let Some(first) = new_lines.first_mut() {
335                    *first = format!("{}{}", prefix, first);
336                }
337            }
338
339            // Append suffix to the last replacement line.
340            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}