1use std::path::Path;
2
3use crate::core::cache::SessionCache;
4use crate::core::tokens::count_tokens;
5
6pub struct EditParams {
8 pub path: String,
9 pub old_string: String,
10 pub new_string: String,
11 pub replace_all: bool,
12 pub create: bool,
13}
14
15struct ReplaceArgs<'a> {
16 content: &'a str,
17 old_str: &'a str,
18 new_str: &'a str,
19 occurrences: usize,
20 replace_all: bool,
21 old_tokens: usize,
22 new_tokens: usize,
23}
24
25pub fn handle(cache: &mut SessionCache, params: &EditParams) -> String {
27 let file_path = ¶ms.path;
28
29 if params.create {
30 return handle_create(cache, file_path, ¶ms.new_string);
31 }
32
33 let cap = crate::core::limits::max_read_bytes();
34 if let Ok(meta) = std::fs::metadata(file_path) {
35 if meta.len() > cap as u64 {
36 return format!(
37 "ERROR: file too large ({} bytes, cap {} via LCTX_MAX_READ_BYTES): {file_path}",
38 meta.len(),
39 cap
40 );
41 }
42 }
43
44 let mut file = match std::fs::OpenOptions::new()
45 .read(true)
46 .write(true)
47 .open(file_path)
48 {
49 Ok(f) => f,
50 Err(e) => return format!("ERROR: cannot open {file_path}: {e}"),
51 };
52
53 let mut raw_bytes: Vec<u8> = Vec::new();
54 {
55 use std::io::Read;
56 let mut limited = (&mut file).take((cap as u64).saturating_add(1));
57 if let Err(e) = limited.read_to_end(&mut raw_bytes) {
58 return format!("ERROR: cannot read {file_path}: {e}");
59 }
60 }
61 if raw_bytes.len() > cap {
62 return format!("ERROR: file too large (cap {cap} via LCTX_MAX_READ_BYTES): {file_path}");
63 }
64
65 let content = String::from_utf8_lossy(&raw_bytes).into_owned();
66
67 if params.old_string.is_empty() {
68 return "ERROR: old_string must not be empty (use create=true to create a new file)".into();
69 }
70
71 let uses_crlf = content.contains("\r\n");
72 let old_str = ¶ms.old_string;
73 let new_str = ¶ms.new_string;
74
75 let occurrences = content.matches(old_str).count();
76
77 if occurrences > 0 {
78 let args = ReplaceArgs {
79 content: &content,
80 old_str,
81 new_str,
82 occurrences,
83 replace_all: params.replace_all,
84 old_tokens: count_tokens(¶ms.old_string),
85 new_tokens: count_tokens(¶ms.new_string),
86 };
87 return do_replace(cache, &mut file, file_path, &args);
88 }
89
90 if uses_crlf && !old_str.contains('\r') {
92 let old_crlf = old_str.replace('\n', "\r\n");
93 let occ = content.matches(&old_crlf).count();
94 if occ > 0 {
95 let new_crlf = new_str.replace('\n', "\r\n");
96 let args = ReplaceArgs {
97 content: &content,
98 old_str: &old_crlf,
99 new_str: &new_crlf,
100 occurrences: occ,
101 replace_all: params.replace_all,
102 old_tokens: count_tokens(¶ms.old_string),
103 new_tokens: count_tokens(¶ms.new_string),
104 };
105 return do_replace(cache, &mut file, file_path, &args);
106 }
107 } else if !uses_crlf && old_str.contains("\r\n") {
108 let old_lf = old_str.replace("\r\n", "\n");
109 let occ = content.matches(&old_lf).count();
110 if occ > 0 {
111 let new_lf = new_str.replace("\r\n", "\n");
112 let args = ReplaceArgs {
113 content: &content,
114 old_str: &old_lf,
115 new_str: &new_lf,
116 occurrences: occ,
117 replace_all: params.replace_all,
118 old_tokens: count_tokens(¶ms.old_string),
119 new_tokens: count_tokens(¶ms.new_string),
120 };
121 return do_replace(cache, &mut file, file_path, &args);
122 }
123 }
124
125 let normalized_content = trim_trailing_per_line(&content);
127 let normalized_old = trim_trailing_per_line(old_str);
128 if !normalized_old.is_empty() && normalized_content.contains(&normalized_old) {
129 let line_sep = if uses_crlf { "\r\n" } else { "\n" };
130 let adapted_new = adapt_new_string_to_line_sep(new_str, line_sep);
131 let adapted_old = find_original_span(&content, &normalized_old);
132 if let Some(original_match) = adapted_old {
133 let occ = content.matches(&original_match).count();
134 let args = ReplaceArgs {
135 content: &content,
136 old_str: &original_match,
137 new_str: &adapted_new,
138 occurrences: occ,
139 replace_all: params.replace_all,
140 old_tokens: count_tokens(¶ms.old_string),
141 new_tokens: count_tokens(¶ms.new_string),
142 };
143 return do_replace(cache, &mut file, file_path, &args);
144 }
145 }
146
147 let preview = if old_str.len() > 80 {
148 format!("{}...", &old_str[..77])
149 } else {
150 old_str.clone()
151 };
152 let hint = if uses_crlf {
153 " (file uses CRLF line endings)"
154 } else {
155 ""
156 };
157 format!(
158 "ERROR: old_string not found in {file_path}{hint}. \
159 Make sure it matches exactly (including whitespace/indentation).\n\
160 Searched for: {preview}"
161 )
162}
163
164fn do_replace(
165 cache: &mut SessionCache,
166 file: &mut std::fs::File,
167 file_path: &str,
168 args: &ReplaceArgs<'_>,
169) -> String {
170 if args.occurrences > 1 && !args.replace_all {
171 return format!(
172 "ERROR: old_string found {} times in {file_path}. \
173 Use replace_all=true to replace all, or provide more context to make old_string unique."
174 , args.occurrences
175 );
176 }
177
178 let new_content = if args.replace_all {
179 args.content.replace(args.old_str, args.new_str)
180 } else {
181 args.content.replacen(args.old_str, args.new_str, 1)
182 };
183
184 use std::io::{Seek, SeekFrom, Write};
185 if let Err(e) = file.set_len(0) {
186 return format!("ERROR: cannot write {file_path}: {e}");
187 }
188 if let Err(e) = file.seek(SeekFrom::Start(0)) {
189 return format!("ERROR: cannot write {file_path}: {e}");
190 }
191 if let Err(e) = file.write_all(new_content.as_bytes()) {
192 return format!("ERROR: cannot write {file_path}: {e}");
193 }
194 let _ = file.flush();
195 let _ = file.sync_all();
196
197 cache.invalidate(file_path);
198
199 let old_lines = args.content.lines().count();
200 let new_lines = new_content.lines().count();
201 let line_delta = new_lines as i64 - old_lines as i64;
202 let delta_str = if line_delta > 0 {
203 format!("+{line_delta}")
204 } else {
205 format!("{line_delta}")
206 };
207
208 let old_tokens = args.old_tokens;
209 let new_tokens = args.new_tokens;
210
211 let replaced_str = if args.replace_all && args.occurrences > 1 {
212 format!("{} replacements", args.occurrences)
213 } else {
214 "1 replacement".into()
215 };
216
217 let short = Path::new(file_path).file_name().map_or_else(
218 || file_path.to_string(),
219 |f| f.to_string_lossy().to_string(),
220 );
221
222 format!("✓ {short}: {replaced_str}, {delta_str} lines ({old_tokens}→{new_tokens} tok)")
223}
224
225fn handle_create(cache: &mut SessionCache, file_path: &str, content: &str) -> String {
226 if let Some(parent) = Path::new(file_path).parent() {
227 if !parent.exists() {
228 if let Err(e) = std::fs::create_dir_all(parent) {
229 return format!("ERROR: cannot create directory {}: {e}", parent.display());
230 }
231 }
232 }
233
234 if let Err(e) = std::fs::write(file_path, content) {
235 return format!("ERROR: cannot write {file_path}: {e}");
236 }
237
238 cache.invalidate(file_path);
239
240 let lines = content.lines().count();
241 let tokens = count_tokens(content);
242 let short = Path::new(file_path).file_name().map_or_else(
243 || file_path.to_string(),
244 |f| f.to_string_lossy().to_string(),
245 );
246
247 format!("✓ created {short}: {lines} lines, {tokens} tok")
248}
249
250fn trim_trailing_per_line(s: &str) -> String {
251 s.lines().map(str::trim_end).collect::<Vec<_>>().join("\n")
252}
253
254fn adapt_new_string_to_line_sep(s: &str, sep: &str) -> String {
255 let normalized = s.replace("\r\n", "\n");
256 if sep == "\r\n" {
257 normalized.replace('\n', "\r\n")
258 } else {
259 normalized
260 }
261}
262
263fn find_original_span(content: &str, normalized_needle: &str) -> Option<String> {
266 let needle_lines: Vec<&str> = normalized_needle.lines().collect();
267 if needle_lines.is_empty() {
268 return None;
269 }
270
271 let content_lines: Vec<&str> = content.lines().collect();
272
273 'outer: for start in 0..content_lines.len() {
274 if start + needle_lines.len() > content_lines.len() {
275 break;
276 }
277 for (i, nl) in needle_lines.iter().enumerate() {
278 if content_lines[start + i].trim_end() != *nl {
279 continue 'outer;
280 }
281 }
282 let sep = if content.contains("\r\n") {
283 "\r\n"
284 } else {
285 "\n"
286 };
287 return Some(content_lines[start..start + needle_lines.len()].join(sep));
288 }
289 None
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use std::io::Write;
296 use tempfile::NamedTempFile;
297
298 fn make_temp(content: &str) -> NamedTempFile {
299 let mut f = NamedTempFile::new().unwrap();
300 f.write_all(content.as_bytes()).unwrap();
301 f
302 }
303
304 #[test]
305 fn replace_single_occurrence() {
306 let f = make_temp("fn hello() {\n println!(\"hello\");\n}\n");
307 let mut cache = SessionCache::new();
308 let result = handle(
309 &mut cache,
310 &EditParams {
311 path: f.path().to_str().unwrap().to_string(),
312 old_string: "hello".into(),
313 new_string: "world".into(),
314 replace_all: false,
315 create: false,
316 },
317 );
318 assert!(result.contains("ERROR"), "should fail: 'hello' appears 2x");
319 }
320
321 #[test]
322 fn replace_all() {
323 let f = make_temp("aaa bbb aaa\n");
324 let mut cache = SessionCache::new();
325 let result = handle(
326 &mut cache,
327 &EditParams {
328 path: f.path().to_str().unwrap().to_string(),
329 old_string: "aaa".into(),
330 new_string: "ccc".into(),
331 replace_all: true,
332 create: false,
333 },
334 );
335 assert!(result.contains("2 replacements"));
336 let content = std::fs::read_to_string(f.path()).unwrap();
337 assert_eq!(content, "ccc bbb ccc\n");
338 }
339
340 #[test]
341 fn not_found_error() {
342 let f = make_temp("some content\n");
343 let mut cache = SessionCache::new();
344 let result = handle(
345 &mut cache,
346 &EditParams {
347 path: f.path().to_str().unwrap().to_string(),
348 old_string: "nonexistent".into(),
349 new_string: "x".into(),
350 replace_all: false,
351 create: false,
352 },
353 );
354 assert!(result.contains("ERROR: old_string not found"));
355 }
356
357 #[test]
358 fn create_new_file() {
359 let dir = tempfile::tempdir().unwrap();
360 let path = dir.path().join("sub/new_file.txt");
361 let mut cache = SessionCache::new();
362 let result = handle(
363 &mut cache,
364 &EditParams {
365 path: path.to_str().unwrap().to_string(),
366 old_string: String::new(),
367 new_string: "line1\nline2\nline3\n".into(),
368 replace_all: false,
369 create: true,
370 },
371 );
372 assert!(result.contains("created new_file.txt"));
373 assert!(result.contains("3 lines"));
374 assert!(path.exists());
375 }
376
377 #[test]
378 fn unique_match_succeeds() {
379 let f = make_temp("fn main() {\n let x = 42;\n}\n");
380 let mut cache = SessionCache::new();
381 let result = handle(
382 &mut cache,
383 &EditParams {
384 path: f.path().to_str().unwrap().to_string(),
385 old_string: "let x = 42".into(),
386 new_string: "let x = 99".into(),
387 replace_all: false,
388 create: false,
389 },
390 );
391 assert!(result.contains("✓"));
392 assert!(result.contains("1 replacement"));
393 let content = std::fs::read_to_string(f.path()).unwrap();
394 assert!(content.contains("let x = 99"));
395 }
396
397 #[test]
398 fn crlf_file_with_lf_search() {
399 let f = make_temp("line1\r\nline2\r\nline3\r\n");
400 let mut cache = SessionCache::new();
401 let result = handle(
402 &mut cache,
403 &EditParams {
404 path: f.path().to_str().unwrap().to_string(),
405 old_string: "line1\nline2".into(),
406 new_string: "changed1\nchanged2".into(),
407 replace_all: false,
408 create: false,
409 },
410 );
411 assert!(result.contains("✓"), "CRLF fallback should work: {result}");
412 let content = std::fs::read_to_string(f.path()).unwrap();
413 assert!(
414 content.contains("changed1\r\nchanged2"),
415 "new_string should be adapted to CRLF: {content:?}"
416 );
417 assert!(
418 content.contains("\r\nline3\r\n"),
419 "rest of file should keep CRLF: {content:?}"
420 );
421 }
422
423 #[test]
424 fn lf_file_with_crlf_search() {
425 let f = make_temp("line1\nline2\nline3\n");
426 let mut cache = SessionCache::new();
427 let result = handle(
428 &mut cache,
429 &EditParams {
430 path: f.path().to_str().unwrap().to_string(),
431 old_string: "line1\r\nline2".into(),
432 new_string: "a\r\nb".into(),
433 replace_all: false,
434 create: false,
435 },
436 );
437 assert!(result.contains("✓"), "LF fallback should work: {result}");
438 let content = std::fs::read_to_string(f.path()).unwrap();
439 assert!(
440 content.contains("a\nb"),
441 "new_string should be adapted to LF: {content:?}"
442 );
443 }
444
445 #[test]
446 fn trailing_whitespace_tolerance() {
447 let f = make_temp(" let x = 1; \n let y = 2;\n");
448 let mut cache = SessionCache::new();
449 let result = handle(
450 &mut cache,
451 &EditParams {
452 path: f.path().to_str().unwrap().to_string(),
453 old_string: " let x = 1;\n let y = 2;".into(),
454 new_string: " let x = 10;\n let y = 20;".into(),
455 replace_all: false,
456 create: false,
457 },
458 );
459 assert!(
460 result.contains("✓"),
461 "trailing whitespace tolerance should work: {result}"
462 );
463 let content = std::fs::read_to_string(f.path()).unwrap();
464 assert!(content.contains("let x = 10;"));
465 assert!(content.contains("let y = 20;"));
466 }
467
468 #[test]
469 fn crlf_with_trailing_whitespace() {
470 let f = make_temp(" const a = 1; \r\n const b = 2;\r\n");
471 let mut cache = SessionCache::new();
472 let result = handle(
473 &mut cache,
474 &EditParams {
475 path: f.path().to_str().unwrap().to_string(),
476 old_string: " const a = 1;\n const b = 2;".into(),
477 new_string: " const a = 10;\n const b = 20;".into(),
478 replace_all: false,
479 create: false,
480 },
481 );
482 assert!(
483 result.contains("✓"),
484 "CRLF + trailing whitespace should work: {result}"
485 );
486 let content = std::fs::read_to_string(f.path()).unwrap();
487 assert!(content.contains("const a = 10;"));
488 assert!(content.contains("const b = 20;"));
489 }
490}