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
29pub fn apply_edit(content: &str, edit: &EditSpec) -> Result<ApplyResult, ToolError> {
32 let old_raw = &edit.old_string;
33 let new_raw = &edit.new_string;
34
35 if old_raw == new_raw {
36 return Err(ToolError::new(
37 ToolErrorCode::NoOpEdit,
38 "old_string equals new_string (no-op edit). If you intended to verify file state, use Read instead.",
39 ));
40 }
41
42 let norm_content = normalize_line_endings(content);
43 let norm_old = normalize_line_endings(old_raw);
44 let norm_new = normalize_line_endings(new_raw);
45
46 if norm_content.is_empty() {
47 return Err(ToolError::new(
48 ToolErrorCode::EmptyFile,
49 "Edit cannot anchor to an empty file. Use Write to create initial content; Edit requires existing text as an anchor.",
50 ));
51 }
52
53 let offsets = find_all_occurrences(&norm_content, &norm_old);
54
55 if offsets.is_empty() {
56 let candidates = find_fuzzy_candidates(&norm_content, &norm_old, FuzzyOpts::default());
57 let block = format_fuzzy_candidates(&candidates);
58 let msg = if !block.is_empty() {
59 format!(
60 "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.",
61 block
62 )
63 } else {
64 "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()
65 };
66 return Err(ToolError::new(ToolErrorCode::OldStringNotFound, msg).with_meta(
67 serde_json::json!({
68 "candidates": candidates,
69 }),
70 ));
71 }
72
73 let replace_all = edit.replace_all.unwrap_or(false);
74 if offsets.len() > 1 && !replace_all {
75 let locations = build_match_locations(&norm_content, &norm_old, &offsets);
76 let block = format_match_locations(&locations);
77 let msg = format!(
78 "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.",
79 offsets.len(),
80 block
81 );
82 return Err(ToolError::new(ToolErrorCode::OldStringNotUnique, msg).with_meta(
83 serde_json::json!({
84 "match_count": offsets.len(),
85 "locations": locations,
86 }),
87 ));
88 }
89
90 let target_offsets: Vec<usize> = if replace_all {
91 offsets.clone()
92 } else {
93 vec![offsets[0]]
94 };
95
96 let mut warnings: Vec<String> = Vec::new();
97 if replace_all && offsets.len() > 1 {
98 let flagged = substring_boundary_collisions(&norm_content, &norm_old, &offsets);
99 if !flagged.is_empty() {
100 let lines_str = flagged
101 .iter()
102 .map(|n| n.to_string())
103 .collect::<Vec<_>>()
104 .join(", ");
105 let needle_preview = truncate_for_warning(&norm_old);
106 warnings.push(format!(
107 "replace_all pattern \"{}\" is adjacent to identifier characters at line(s) {}; verify these replacements did not land inside a larger identifier.",
108 needle_preview, lines_str
109 ));
110 }
111 }
112
113 let new_content = replace_at_offsets(&norm_content, &norm_old, &norm_new, &target_offsets);
114
115 Ok(ApplyResult {
116 content: new_content,
117 replacements: target_offsets.len(),
118 warnings,
119 })
120}
121
122pub fn apply_pipeline(initial: &str, edits: &[EditSpec]) -> PipelineResult {
123 let mut content = initial.to_string();
124 let mut total = 0usize;
125 let mut warnings: Vec<String> = Vec::new();
126
127 for (i, edit) in edits.iter().enumerate() {
128 match apply_edit(&content, edit) {
129 Ok(r) => {
130 content = r.content;
131 total += r.replacements;
132 for w in r.warnings {
133 warnings.push(format!("edit[{}]: {}", i, w));
134 }
135 }
136 Err(e) => {
137 let msg = format!("edit[{}]: {}", i, e.message);
138 let mut meta = e.meta.clone().unwrap_or_else(|| serde_json::json!({}));
139 meta["edit_index"] = serde_json::json!(i);
140 let new_err = ToolError::new(e.code, msg).with_meta(meta);
141 return PipelineResult::Err {
142 error: new_err,
143 index: i,
144 };
145 }
146 }
147 }
148
149 PipelineResult::Ok {
150 content,
151 total_replacements: total,
152 warnings,
153 }
154}
155
156fn replace_at_offsets(
157 haystack: &str,
158 needle: &str,
159 replacement: &str,
160 offsets: &[usize],
161) -> String {
162 if offsets.is_empty() {
163 return haystack.to_string();
164 }
165 let needle_len = needle.as_bytes().len();
166 let mut out = String::with_capacity(haystack.len());
167 let mut cursor = 0usize;
168 for &off in offsets {
169 out.push_str(&haystack[cursor..off]);
170 out.push_str(replacement);
171 cursor = off + needle_len;
172 }
173 out.push_str(&haystack[cursor..]);
174 out
175}
176
177fn truncate_for_warning(s: &str) -> String {
178 let one_line: String = s.chars().map(|c| if c == '\n' { ' ' } else { c }).collect();
179 if one_line.chars().count() <= 40 {
180 one_line
181 } else {
182 let mut out: String = one_line.chars().take(37).collect();
183 out.push_str("...");
184 out
185 }
186}