1use std::path::{Path, PathBuf};
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use crate::core::cache::SessionCache;
5use crate::core::tokens::count_tokens;
6
7pub struct EditParams {
9 pub path: String,
10 pub old_string: String,
11 pub new_string: String,
12 pub replace_all: bool,
13 pub create: bool,
14 pub expected_md5: Option<String>,
16 pub expected_size: Option<u64>,
17 pub expected_mtime_ms: Option<u64>,
18 pub backup: bool,
20 pub backup_path: Option<String>,
21 pub evidence: bool,
23 pub diff_max_lines: usize,
24 pub allow_lossy_utf8: bool,
26}
27
28struct ReplaceArgs<'a> {
29 content: &'a str,
30 old_str: &'a str,
31 new_str: &'a str,
32 occurrences: usize,
33 replace_all: bool,
34 old_tokens: usize,
35 new_tokens: usize,
36}
37
38#[derive(Clone, Debug, PartialEq, Eq)]
39struct FileFingerprint {
40 size: u64,
41 mtime_ms: u64,
42 md5: String,
43}
44
45#[derive(Clone, Debug)]
46struct FilePreimage {
47 fp: FileFingerprint,
48 permissions: std::fs::Permissions,
49 bytes: Vec<u8>,
50 text: String,
51 uses_crlf: bool,
52}
53
54fn system_time_to_millis(t: SystemTime) -> u64 {
55 t.duration_since(UNIX_EPOCH)
56 .map_or(0, |d| d.as_millis() as u64)
57}
58
59fn reject_symlink(path: &Path) -> Result<(), String> {
63 if let Ok(meta) = std::fs::symlink_metadata(path) {
64 if crate::core::pathutil::is_symlink_or_reparse(&meta) {
66 return Err(format!(
67 "ERROR: {} is a symlink — refusing to edit through it (TOCTOU protection). \
68 Edit the symlink target directly via its real path.",
69 path.display()
70 ));
71 }
72 }
73 Ok(())
74}
75
76fn read_file_bytes_limited(
77 path: &Path,
78 cap: usize,
79) -> Result<(Vec<u8>, std::fs::Metadata), String> {
80 reject_symlink(path)?;
81
82 if let Ok(meta) = std::fs::metadata(path)
83 && meta.len() > cap as u64
84 {
85 return Err(format!(
86 "ERROR: file too large ({} bytes, cap {} via LCTX_MAX_READ_BYTES): {}",
87 meta.len(),
88 cap,
89 path.display()
90 ));
91 }
92
93 let mut opts = std::fs::OpenOptions::new();
94 opts.read(true);
95 #[cfg(unix)]
96 {
97 use std::os::unix::fs::OpenOptionsExt;
100 opts.custom_flags(libc::O_NOFOLLOW);
101 }
102 let mut file = opts.open(path).map_err(|e| {
103 #[cfg(unix)]
104 if e.raw_os_error() == Some(libc::ELOOP) {
105 return format!(
106 "ERROR: {} is a symlink — refusing to edit through it (TOCTOU protection).",
107 path.display()
108 );
109 }
110 format!("ERROR: cannot open {}: {e}", path.display())
111 })?;
112
113 use std::io::Read;
114 let mut raw: Vec<u8> = Vec::new();
115 let mut limited = (&mut file).take((cap as u64).saturating_add(1));
116 limited
117 .read_to_end(&mut raw)
118 .map_err(|e| format!("ERROR: cannot read {}: {e}", path.display()))?;
119 if raw.len() > cap {
120 return Err(format!(
121 "ERROR: file too large (cap {} via LCTX_MAX_READ_BYTES): {}",
122 cap,
123 path.display()
124 ));
125 }
126
127 let meta = file
128 .metadata()
129 .map_err(|e| format!("ERROR: cannot stat {}: {e}", path.display()))?;
130 Ok((raw, meta))
131}
132
133fn fingerprint_from_bytes(bytes: &[u8], meta: &std::fs::Metadata) -> FileFingerprint {
134 FileFingerprint {
135 size: bytes.len() as u64,
136 mtime_ms: meta.modified().map_or(0, system_time_to_millis),
137 md5: crate::core::hasher::hash_hex(bytes),
138 }
139}
140
141fn read_preimage(path: &Path, cap: usize, allow_lossy_utf8: bool) -> Result<FilePreimage, String> {
142 let (bytes, meta) = read_file_bytes_limited(path, cap)?;
143 let permissions = meta.permissions();
144 let fp = fingerprint_from_bytes(&bytes, &meta);
145
146 let text = if allow_lossy_utf8 {
147 String::from_utf8_lossy(&bytes).into_owned()
148 } else {
149 String::from_utf8(bytes.clone()).map_err(|_| {
150 format!(
151 "ERROR: file is not valid UTF-8 (binary/encoding). Refusing to edit: {}",
152 path.display()
153 )
154 })?
155 };
156 let uses_crlf = text.contains("\r\n");
157
158 Ok(FilePreimage {
159 fp,
160 permissions,
161 bytes,
162 text,
163 uses_crlf,
164 })
165}
166
167fn verify_expected_preimage(pre: &FilePreimage, params: &EditParams) -> Result<(), String> {
168 if let Some(expected) = params.expected_size
169 && expected != pre.fp.size
170 {
171 return Err(format!(
172 "ERROR: preimage mismatch for {}: expected_size={}, actual_size={}",
173 params.path, expected, pre.fp.size
174 ));
175 }
176 if let Some(expected) = params.expected_mtime_ms
177 && expected != pre.fp.mtime_ms
178 {
179 return Err(format!(
180 "ERROR: preimage mismatch for {}: expected_mtime_ms={}, actual_mtime_ms={}",
181 params.path, expected, pre.fp.mtime_ms
182 ));
183 }
184 if let Some(expected) = params.expected_md5.as_deref()
185 && expected != pre.fp.md5
186 {
187 return Err(format!(
188 "ERROR: preimage mismatch for {}: expected_md5={}, actual_md5={}",
189 params.path, expected, pre.fp.md5
190 ));
191 }
192 Ok(())
193}
194
195fn ensure_preimage_still_matches(
196 path: &Path,
197 expected: &FileFingerprint,
198 cap: usize,
199) -> Result<(), String> {
200 let (bytes, meta) = read_file_bytes_limited(path, cap)?;
201 let now = fingerprint_from_bytes(&bytes, &meta);
202 if &now != expected {
203 return Err(format!(
204 "ERROR: file changed since read (TOCTOU guard). Re-read and retry: {}\nexpected: size={}, mtime_ms={}, md5={}\nactual: size={}, mtime_ms={}, md5={}",
205 path.display(),
206 expected.size,
207 expected.mtime_ms,
208 expected.md5,
209 now.size,
210 now.mtime_ms,
211 now.md5
212 ));
213 }
214 Ok(())
215}
216
217fn default_backup_path(path: &Path) -> Option<PathBuf> {
218 let parent = path.parent()?;
219 let filename = path.file_name()?.to_string_lossy();
220 let pid = std::process::id();
221 let nanos = SystemTime::now()
222 .duration_since(UNIX_EPOCH)
223 .map_or(0, |d| d.as_nanos());
224 Some(parent.join(format!("{filename}.lean-ctx.bak.{pid}.{nanos}")))
225}
226
227fn write_atomic_bytes_with_permissions(
228 path: &Path,
229 bytes: &[u8],
230 permissions: Option<&std::fs::Permissions>,
231) -> Result<(), String> {
232 reject_symlink(path)?;
237
238 if let Some(parent) = path.parent() {
239 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
240 }
241
242 let parent = path
243 .parent()
244 .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
245 let filename = path
246 .file_name()
247 .ok_or_else(|| "invalid path (no filename)".to_string())?
248 .to_string_lossy();
249
250 let pid = std::process::id();
251 let nanos = SystemTime::now()
252 .duration_since(UNIX_EPOCH)
253 .map_or(0, |d| d.as_nanos());
254 let tmp = parent.join(format!(".{filename}.lean-ctx.tmp.{pid}.{nanos}"));
255
256 {
257 use std::io::Write;
258 let mut f = std::fs::OpenOptions::new()
259 .write(true)
260 .create_new(true)
261 .open(&tmp)
262 .map_err(|e| format!("ERROR: cannot write {}: {e}", tmp.display()))?;
263 f.write_all(bytes)
264 .map_err(|e| format!("ERROR: cannot write {}: {e}", tmp.display()))?;
265 let _ = f.flush();
266 let _ = f.sync_all();
267 }
268
269 if let Some(perms) = permissions {
270 let _ = std::fs::set_permissions(&tmp, perms.clone());
271 }
272
273 #[cfg(windows)]
274 {
275 if path.exists() {
276 let _ = std::fs::remove_file(path);
277 }
278 }
279
280 std::fs::rename(&tmp, path).map_err(|e| {
281 format!(
282 "ERROR: atomic write failed: {} (tmp: {})",
283 e,
284 tmp.to_string_lossy()
285 )
286 })?;
287
288 Ok(())
289}
290
291fn build_diff_evidence(old: &str, new: &str, label: &str, max_lines: usize) -> String {
292 let diff = similar::TextDiff::from_lines(old, new)
293 .unified_diff()
294 .context_radius(3)
295 .header(label, label)
296 .to_string();
297 let diff = crate::core::redaction::redact_text(&diff);
302
303 let mut out = String::new();
304 for (i, line) in diff.lines().enumerate() {
305 if i >= max_lines {
306 out.push_str(&format!("\n... diff truncated (max_lines={max_lines})"));
307 break;
308 }
309 out.push_str(line);
310 out.push('\n');
311 }
312 out.trim_end_matches('\n').to_string()
313}
314
315pub enum CacheEffect {
322 None,
324 Invalidate,
326 StoreFull(String),
329}
330
331pub fn handle(cache: &mut SessionCache, params: &EditParams) -> String {
336 let last_mode = cache
337 .get(¶ms.path)
338 .map(|e| e.last_mode.clone())
339 .unwrap_or_default();
340 let (text, effect) = run_io(params, &last_mode);
341 record_outcome(params, &last_mode, &text, &effect);
342 apply_cache_effect(cache, ¶ms.path, effect);
343 text
344}
345
346pub fn record_outcome(params: &EditParams, last_mode: &str, text: &str, effect: &CacheEffect) {
353 if params.create {
354 return;
355 }
356 let success = matches!(effect, CacheEffect::Invalidate);
357 let not_found_failure = matches!(effect, CacheEffect::StoreFull(_))
358 || (matches!(effect, CacheEffect::None)
359 && text.starts_with("ERROR: old_string not found")
360 && !text.contains("already"));
361 if success || not_found_failure {
362 crate::core::edit_quality::record_edit_outcome(¶ms.path, last_mode, success);
363 }
364}
365
366pub fn apply_cache_effect(cache: &mut SessionCache, path: &str, effect: CacheEffect) {
368 match effect {
369 CacheEffect::None => {}
370 CacheEffect::Invalidate => {
371 cache.invalidate(path);
372 }
373 CacheEffect::StoreFull(content) => {
374 cache.store(path, &content);
375 cache.mark_full_delivered(path);
376 }
377 }
378}
379
380pub fn run_io(params: &EditParams, last_mode: &str) -> (String, CacheEffect) {
386 let file_path = ¶ms.path;
387
388 if params.create {
389 return handle_create(file_path, ¶ms.new_string, params);
390 }
391
392 let cap = crate::core::limits::max_read_bytes();
393 let path = Path::new(file_path);
394 let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
395 Ok(p) => p,
396 Err(e) => {
397 if !path.exists() {
400 let hint = crate::tools::edit_recovery::moved_or_deleted_hint(path);
401 return (format!("{e}{hint}"), CacheEffect::None);
402 }
403 return (e, CacheEffect::None);
404 }
405 };
406 if let Err(e) = verify_expected_preimage(&pre, params) {
407 return (e, CacheEffect::None);
408 }
409 let content = &pre.text;
410
411 if params.old_string.is_empty() {
412 return (
413 "ERROR: old_string must not be empty (use create=true to create a new file)".into(),
414 CacheEffect::None,
415 );
416 }
417
418 if params.old_string == params.new_string {
419 return (
420 "ERROR: old_string and new_string are identical — nothing to change.".into(),
421 CacheEffect::None,
422 );
423 }
424
425 let uses_crlf = pre.uses_crlf;
426 let old_str = ¶ms.old_string;
427 let new_str = ¶ms.new_string;
428
429 let occurrences = content.matches(old_str).count();
430
431 if occurrences > 0 {
432 let args = ReplaceArgs {
433 content,
434 old_str,
435 new_str,
436 occurrences,
437 replace_all: params.replace_all,
438 old_tokens: count_tokens(¶ms.old_string),
439 new_tokens: count_tokens(¶ms.new_string),
440 };
441 return do_replace(path, &pre, params, cap, &args);
442 }
443
444 if uses_crlf && !old_str.contains('\r') {
446 let old_crlf = old_str.replace('\n', "\r\n");
447 let occ = content.matches(&old_crlf).count();
448 if occ > 0 {
449 let new_crlf = new_str.replace('\n', "\r\n");
450 let args = ReplaceArgs {
451 content,
452 old_str: &old_crlf,
453 new_str: &new_crlf,
454 occurrences: occ,
455 replace_all: params.replace_all,
456 old_tokens: count_tokens(¶ms.old_string),
457 new_tokens: count_tokens(¶ms.new_string),
458 };
459 return do_replace(path, &pre, params, cap, &args);
460 }
461 } else if !uses_crlf && old_str.contains("\r\n") {
462 let old_lf = old_str.replace("\r\n", "\n");
463 let occ = content.matches(&old_lf).count();
464 if occ > 0 {
465 let new_lf = new_str.replace("\r\n", "\n");
466 let args = ReplaceArgs {
467 content,
468 old_str: &old_lf,
469 new_str: &new_lf,
470 occurrences: occ,
471 replace_all: params.replace_all,
472 old_tokens: count_tokens(¶ms.old_string),
473 new_tokens: count_tokens(¶ms.new_string),
474 };
475 return do_replace(path, &pre, params, cap, &args);
476 }
477 }
478
479 let normalized_content = trim_trailing_per_line(content);
481 let normalized_old = trim_trailing_per_line(old_str);
482 if !normalized_old.is_empty() && normalized_content.contains(&normalized_old) {
483 let line_sep = if uses_crlf { "\r\n" } else { "\n" };
484 let adapted_new = adapt_new_string_to_line_sep(new_str, line_sep);
485 let adapted_old = find_original_span(content, &normalized_old);
486 if let Some(original_match) = adapted_old {
487 let occ = content.matches(&original_match).count();
488 let args = ReplaceArgs {
489 content,
490 old_str: &original_match,
491 new_str: &adapted_new,
492 occurrences: occ,
493 replace_all: params.replace_all,
494 old_tokens: count_tokens(¶ms.old_string),
495 new_tokens: count_tokens(¶ms.new_string),
496 };
497 return do_replace(path, &pre, params, cap, &args);
498 }
499 }
500
501 if content.contains(new_str) {
503 return (
504 format!(
505 "ERROR: old_string not found in {file_path}, but new_string already exists in the file. \
506 The edit was likely already applied (by a previous tool call or another agent)."
507 ),
508 CacheEffect::None,
509 );
510 }
511
512 let preview = if old_str.len() > 80 {
513 format!("{}...", &old_str[..old_str.floor_char_boundary(77)])
514 } else {
515 old_str.clone()
516 };
517 let hint = if uses_crlf {
518 " (file uses CRLF line endings)"
519 } else {
520 ""
521 };
522
523 let closest_hint = find_closest_line_hint(content, old_str);
525 let cross_file = crate::tools::edit_recovery::cross_file_hint(path, old_str);
527
528 let (escalation, effect) = auto_escalate_reread(last_mode, file_path);
529
530 (
531 format!(
532 "ERROR: old_string not found in {file_path}{hint}. \
533 Make sure it matches exactly (including whitespace/indentation).\n\
534 Searched for: {preview}{closest_hint}{cross_file}{escalation}"
535 ),
536 effect,
537 )
538}
539
540fn find_closest_line_hint(content: &str, old_str: &str) -> String {
543 let first_line = old_str.lines().next().unwrap_or("").trim();
544 if first_line.len() < 4 {
545 return String::new();
546 }
547
548 let mut best_line: Option<(usize, &str)> = None;
549
550 for (i, line) in content.lines().enumerate() {
552 if line.contains(first_line) {
553 best_line = Some((i + 1, line));
554 break;
555 }
556 }
557
558 if best_line.is_none() {
560 let keywords: Vec<&str> = first_line
561 .split(|c: char| !c.is_alphanumeric() && c != '_')
562 .filter(|w| w.len() >= 4)
563 .collect();
564
565 if let Some(keyword) = keywords.first() {
566 for (i, line) in content.lines().enumerate() {
567 if line.contains(keyword) {
568 best_line = Some((i + 1, line));
569 break;
570 }
571 }
572 }
573 }
574
575 match best_line {
576 Some((line_num, line_content)) => {
577 let trimmed = line_content.trim();
578 let preview = if trimmed.len() > 100 {
579 format!("{}...", &trimmed[..trimmed.floor_char_boundary(97)])
580 } else {
581 trimmed.to_string()
582 };
583 format!(
584 "\nClosest match at line {line_num}: `{preview}`\n\
585 Hint: check indentation/whitespace differences."
586 )
587 }
588 None => String::new(),
589 }
590}
591
592fn auto_escalate_reread(last_mode: &str, path: &str) -> (String, CacheEffect) {
597 if last_mode.is_empty() || last_mode == "full" {
598 return (String::new(), CacheEffect::None);
599 }
600
601 let Ok(fresh_content) = std::fs::read_to_string(path) else {
602 return (String::new(), CacheEffect::None);
603 };
604
605 let line_count = fresh_content.lines().count();
606 const MAX_LINES: usize = 300;
607
608 let content_preview = if line_count <= MAX_LINES {
609 fresh_content.clone()
610 } else {
611 let lines: Vec<&str> = fresh_content.lines().collect();
612 let head = &lines[..MAX_LINES / 2];
613 let tail = &lines[line_count - MAX_LINES / 2..];
614 let omitted = line_count - MAX_LINES;
615 format!(
616 "{}\n[... {omitted} lines omitted ...]\n{}",
617 head.join("\n"),
618 tail.join("\n")
619 )
620 };
621
622 (
623 format!(
624 "\n\n[auto-escalation] Last read used mode=\"{last_mode}\". \
625 Full content ({line_count}L) below — retry edit with exact text from here:\n\n{content_preview}"
626 ),
627 CacheEffect::StoreFull(fresh_content),
628 )
629}
630
631fn do_replace(
632 path: &Path,
633 pre: &FilePreimage,
634 params: &EditParams,
635 cap: usize,
636 args: &ReplaceArgs<'_>,
637) -> (String, CacheEffect) {
638 if args.occurrences > 1 && !args.replace_all {
639 return (
640 format!(
641 "ERROR: old_string found {} times in {}. \
642 Use replace_all=true to replace all, or provide more context to make old_string unique.",
643 args.occurrences,
644 path.display()
645 ),
646 CacheEffect::None,
647 );
648 }
649
650 let new_content = if args.replace_all {
651 args.content.replace(args.old_str, args.new_str)
652 } else {
653 args.content.replacen(args.old_str, args.new_str, 1)
654 };
655
656 if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
657 return (e, CacheEffect::None);
658 }
659
660 let backup_path = if params.backup {
661 let bp = params
662 .backup_path
663 .as_deref()
664 .map(PathBuf::from)
665 .or_else(|| default_backup_path(path));
666 let Some(bp) = bp else {
667 return (
668 format!("ERROR: cannot compute backup path for {}", path.display()),
669 CacheEffect::None,
670 );
671 };
672 if let Err(e) = write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
673 {
674 return (
675 format!("ERROR: cannot create backup {}: {e}", bp.display()),
676 CacheEffect::None,
677 );
678 }
679 Some(bp.to_string_lossy().to_string())
680 } else {
681 None
682 };
683
684 if let Err(e) =
685 write_atomic_bytes_with_permissions(path, new_content.as_bytes(), Some(&pre.permissions))
686 {
687 return (e, CacheEffect::None);
688 }
689
690 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
691 bt.record_edit(¶ms.path);
692 }
693
694 let old_lines = args.content.lines().count();
695 let new_lines = new_content.lines().count();
696 let line_delta = new_lines as i64 - old_lines as i64;
697 let delta_str = if line_delta > 0 {
698 format!("+{line_delta}")
699 } else {
700 format!("{line_delta}")
701 };
702
703 let old_tokens = args.old_tokens;
704 let new_tokens = args.new_tokens;
705
706 let replaced_str = if args.replace_all && args.occurrences > 1 {
707 format!("{} replacements", args.occurrences)
708 } else {
709 "1 replacement".into()
710 };
711
712 let short = path.file_name().map_or_else(
713 || path.to_string_lossy().to_string(),
714 |f| f.to_string_lossy().to_string(),
715 );
716
717 let post_mtime_ms = std::fs::metadata(path)
718 .ok()
719 .and_then(|m| m.modified().ok())
720 .map_or(0, system_time_to_millis);
721 let post_fp = FileFingerprint {
722 size: new_content.len() as u64,
723 mtime_ms: post_mtime_ms,
724 md5: crate::core::hasher::hash_hex(new_content.as_bytes()),
725 };
726
727 let mut out = format!(
728 "✓ {short}: {replaced_str}, {delta_str} lines ({old_tokens}→{new_tokens} tok)\n\
729preimage: bytes={}, mtime_ms={}, md5={}\n\
730postimage: bytes={}, mtime_ms={}, md5={}",
731 pre.fp.size, pre.fp.mtime_ms, pre.fp.md5, post_fp.size, post_fp.mtime_ms, post_fp.md5
732 );
733 if let Some(bp) = backup_path {
734 out.push_str(&format!("\nbackup: {bp}"));
735 }
736 if params.evidence {
737 let diff = build_diff_evidence(args.content, &new_content, &short, params.diff_max_lines);
738 out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
739 out.push_str(&diff);
740 out.push_str("\n```");
741 }
742 (out, CacheEffect::Invalidate)
743}
744
745fn handle_create(file_path: &str, content: &str, params: &EditParams) -> (String, CacheEffect) {
746 let path = Path::new(file_path);
747 let cap = crate::core::limits::max_read_bytes();
748
749 let mut preimage: Option<FilePreimage> = None;
750 if path.exists() {
751 let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
752 Ok(p) => p,
753 Err(e) => return (e, CacheEffect::None),
754 };
755 if let Err(e) = verify_expected_preimage(&pre, params) {
756 return (e, CacheEffect::None);
757 }
758 if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
759 return (e, CacheEffect::None);
760 }
761 preimage = Some(pre);
762 }
763
764 if let Some(parent) = path.parent()
765 && !parent.exists()
766 && let Err(e) = std::fs::create_dir_all(parent)
767 {
768 return (
769 format!("ERROR: cannot create directory {}: {e}", parent.display()),
770 CacheEffect::None,
771 );
772 }
773
774 let backup_path = if params.backup {
775 if let Some(pre) = &preimage {
776 let bp = params
777 .backup_path
778 .as_deref()
779 .map(PathBuf::from)
780 .or_else(|| default_backup_path(path));
781 let Some(bp) = bp else {
782 return (
783 format!("ERROR: cannot compute backup path for {}", path.display()),
784 CacheEffect::None,
785 );
786 };
787 if let Err(e) =
788 write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
789 {
790 return (
791 format!("ERROR: cannot create backup {}: {e}", bp.display()),
792 CacheEffect::None,
793 );
794 }
795 Some(bp.to_string_lossy().to_string())
796 } else {
797 None
798 }
799 } else {
800 None
801 };
802
803 let perms = preimage.as_ref().map(|p| &p.permissions);
804 if let Err(e) = write_atomic_bytes_with_permissions(path, content.as_bytes(), perms) {
805 return (e, CacheEffect::None);
806 }
807
808 let lines = content.lines().count();
809 let tokens = count_tokens(content);
810 let short = path.file_name().map_or_else(
811 || path.to_string_lossy().to_string(),
812 |f| f.to_string_lossy().to_string(),
813 );
814
815 let mut out = format!("✓ created {short}: {lines} lines, {tokens} tok");
816 if let Some(bp) = backup_path {
817 out.push_str(&format!("\nbackup: {bp}"));
818 }
819 (out, CacheEffect::Invalidate)
820}
821
822fn trim_trailing_per_line(s: &str) -> String {
823 s.lines().map(str::trim_end).collect::<Vec<_>>().join("\n")
824}
825
826fn adapt_new_string_to_line_sep(s: &str, sep: &str) -> String {
827 let normalized = s.replace("\r\n", "\n");
828 if sep == "\r\n" {
829 normalized.replace('\n', "\r\n")
830 } else {
831 normalized
832 }
833}
834
835fn find_original_span(content: &str, normalized_needle: &str) -> Option<String> {
838 let needle_lines: Vec<&str> = normalized_needle.lines().collect();
839 if needle_lines.is_empty() {
840 return None;
841 }
842
843 let content_lines: Vec<&str> = content.lines().collect();
844
845 'outer: for start in 0..content_lines.len() {
846 if start + needle_lines.len() > content_lines.len() {
847 break;
848 }
849 for (i, nl) in needle_lines.iter().enumerate() {
850 if content_lines[start + i].trim_end() != *nl {
851 continue 'outer;
852 }
853 }
854 let sep = if content.contains("\r\n") {
855 "\r\n"
856 } else {
857 "\n"
858 };
859 return Some(content_lines[start..start + needle_lines.len()].join(sep));
860 }
861 None
862}
863
864#[cfg(test)]
865mod tests {
866 use super::*;
867 use std::io::Write;
868 use tempfile::NamedTempFile;
869
870 fn make_temp(content: &str) -> NamedTempFile {
871 let mut f = NamedTempFile::new().unwrap();
872 f.write_all(content.as_bytes()).unwrap();
873 f
874 }
875
876 fn mk_params(path: &Path, old: &str, new: &str, replace_all: bool, create: bool) -> EditParams {
877 EditParams {
878 path: path.to_string_lossy().to_string(),
879 old_string: old.to_string(),
880 new_string: new.to_string(),
881 replace_all,
882 create,
883 expected_md5: None,
884 expected_size: None,
885 expected_mtime_ms: None,
886 backup: false,
887 backup_path: None,
888 evidence: false,
889 diff_max_lines: 200,
890 allow_lossy_utf8: false,
891 }
892 }
893
894 #[test]
895 fn replace_single_occurrence() {
896 let f = make_temp("fn hello() {\n println!(\"hello\");\n}\n");
897 let mut cache = SessionCache::new();
898 let result = handle(
899 &mut cache,
900 &mk_params(f.path(), "hello", "world", false, false),
901 );
902 assert!(result.contains("ERROR"), "should fail: 'hello' appears 2x");
903 }
904
905 #[test]
906 fn replace_all() {
907 let f = make_temp("aaa bbb aaa\n");
908 let mut cache = SessionCache::new();
909 let result = handle(&mut cache, &mk_params(f.path(), "aaa", "ccc", true, false));
910 assert!(result.contains("2 replacements"));
911 let content = std::fs::read_to_string(f.path()).unwrap();
912 assert_eq!(content, "ccc bbb ccc\n");
913 }
914
915 #[test]
916 fn not_found_error() {
917 let f = make_temp("some content\n");
918 let mut cache = SessionCache::new();
919 let result = handle(
920 &mut cache,
921 &mk_params(f.path(), "nonexistent", "x", false, false),
922 );
923 assert!(result.contains("ERROR: old_string not found"));
924 }
925
926 #[test]
927 fn create_new_file() {
928 let dir = tempfile::tempdir().unwrap();
929 let path = dir.path().join("sub/new_file.txt");
930 let mut cache = SessionCache::new();
931 let result = handle(
932 &mut cache,
933 &mk_params(&path, "", "line1\nline2\nline3\n", false, true),
934 );
935 assert!(result.contains("created new_file.txt"));
936 assert!(result.contains("3 lines"));
937 assert!(path.exists());
938 }
939
940 #[test]
941 fn unique_match_succeeds() {
942 let f = make_temp("fn main() {\n let x = 42;\n}\n");
943 let mut cache = SessionCache::new();
944 let result = handle(
945 &mut cache,
946 &mk_params(f.path(), "let x = 42", "let x = 99", false, false),
947 );
948 assert!(result.contains("✓"));
949 assert!(result.contains("1 replacement"));
950 let content = std::fs::read_to_string(f.path()).unwrap();
951 assert!(content.contains("let x = 99"));
952 }
953
954 #[test]
955 fn crlf_file_with_lf_search() {
956 let f = make_temp("line1\r\nline2\r\nline3\r\n");
957 let mut cache = SessionCache::new();
958 let result = handle(
959 &mut cache,
960 &mk_params(f.path(), "line1\nline2", "changed1\nchanged2", false, false),
961 );
962 assert!(result.contains("✓"), "CRLF fallback should work: {result}");
963 let content = std::fs::read_to_string(f.path()).unwrap();
964 assert!(
965 content.contains("changed1\r\nchanged2"),
966 "new_string should be adapted to CRLF: {content:?}"
967 );
968 assert!(
969 content.contains("\r\nline3\r\n"),
970 "rest of file should keep CRLF: {content:?}"
971 );
972 }
973
974 #[test]
975 fn lf_file_with_crlf_search() {
976 let f = make_temp("line1\nline2\nline3\n");
977 let mut cache = SessionCache::new();
978 let result = handle(
979 &mut cache,
980 &mk_params(f.path(), "line1\r\nline2", "a\r\nb", false, false),
981 );
982 assert!(result.contains("✓"), "LF fallback should work: {result}");
983 let content = std::fs::read_to_string(f.path()).unwrap();
984 assert!(
985 content.contains("a\nb"),
986 "new_string should be adapted to LF: {content:?}"
987 );
988 }
989
990 #[test]
991 fn trailing_whitespace_tolerance() {
992 let f = make_temp(" let x = 1; \n let y = 2;\n");
993 let mut cache = SessionCache::new();
994 let result = handle(
995 &mut cache,
996 &mk_params(
997 f.path(),
998 " let x = 1;\n let y = 2;",
999 " let x = 10;\n let y = 20;",
1000 false,
1001 false,
1002 ),
1003 );
1004 assert!(
1005 result.contains("✓"),
1006 "trailing whitespace tolerance should work: {result}"
1007 );
1008 let content = std::fs::read_to_string(f.path()).unwrap();
1009 assert!(content.contains("let x = 10;"));
1010 assert!(content.contains("let y = 20;"));
1011 }
1012
1013 #[test]
1014 fn crlf_with_trailing_whitespace() {
1015 let f = make_temp(" const a = 1; \r\n const b = 2;\r\n");
1016 let mut cache = SessionCache::new();
1017 let result = handle(
1018 &mut cache,
1019 &mk_params(
1020 f.path(),
1021 " const a = 1;\n const b = 2;",
1022 " const a = 10;\n const b = 20;",
1023 false,
1024 false,
1025 ),
1026 );
1027 assert!(
1028 result.contains("✓"),
1029 "CRLF + trailing whitespace should work: {result}"
1030 );
1031 let content = std::fs::read_to_string(f.path()).unwrap();
1032 assert!(content.contains("const a = 10;"));
1033 assert!(content.contains("const b = 20;"));
1034 }
1035
1036 #[test]
1037 fn rejects_invalid_utf8_by_default() {
1038 let mut f = NamedTempFile::new().unwrap();
1039 f.write_all(&[0xff, 0xfe, 0xfd]).unwrap();
1040 let mut cache = SessionCache::new();
1041 let result = handle(&mut cache, &mk_params(f.path(), "a", "b", false, false));
1042 assert!(
1043 result.contains("not valid UTF-8"),
1044 "expected utf8 rejection, got: {result}"
1045 );
1046 }
1047
1048 #[test]
1049 fn allows_lossy_utf8_only_when_enabled() {
1050 let mut f = NamedTempFile::new().unwrap();
1051 f.write_all(&[0xff, 0xfe, 0xfd]).unwrap();
1052 let mut cache = SessionCache::new();
1053 let mut p = mk_params(f.path(), "a", "b", false, false);
1054 p.allow_lossy_utf8 = true;
1055 let result = handle(&mut cache, &p);
1056 assert!(
1057 !result.contains("not valid UTF-8"),
1058 "lossy mode should avoid utf8 hard error, got: {result}"
1059 );
1060 }
1061
1062 #[test]
1063 fn expected_md5_mismatch_fails_without_writing() {
1064 let f = make_temp("aaa\n");
1065 let mut cache = SessionCache::new();
1066 let mut p = mk_params(f.path(), "aaa", "bbb", false, false);
1067 p.expected_md5 = Some("deadbeef".to_string());
1068 let result = handle(&mut cache, &p);
1069 assert!(
1070 result.contains("preimage mismatch"),
1071 "expected preimage mismatch, got: {result}"
1072 );
1073 let content = std::fs::read_to_string(f.path()).unwrap();
1074 assert_eq!(content, "aaa\n");
1075 }
1076
1077 #[test]
1078 fn backup_is_created_when_enabled() {
1079 let f = make_temp("aaa\n");
1080 let mut cache = SessionCache::new();
1081 let mut p = mk_params(f.path(), "aaa", "bbb", false, false);
1082 p.backup = true;
1083 let out = handle(&mut cache, &p);
1084 assert!(out.contains("backup:"), "expected backup path, got: {out}");
1085 let bp = out
1086 .lines()
1087 .find_map(|l| l.strip_prefix("backup: "))
1088 .expect("backup line");
1089 let backup_content = std::fs::read_to_string(bp).unwrap();
1090 assert_eq!(backup_content, "aaa\n");
1091 let content = std::fs::read_to_string(f.path()).unwrap();
1092 assert_eq!(content, "bbb\n");
1093 }
1094
1095 #[test]
1096 fn evidence_diff_is_emitted_when_enabled() {
1097 let f = make_temp("line1\nline2\n");
1098 let mut cache = SessionCache::new();
1099 let mut p = mk_params(f.path(), "line2", "changed2", false, false);
1100 p.evidence = true;
1101 p.diff_max_lines = 50;
1102 let out = handle(&mut cache, &p);
1103 assert!(out.contains("```diff"), "expected diff fence, got: {out}");
1104 assert!(
1105 out.contains("preimage:"),
1106 "expected preimage metadata, got: {out}"
1107 );
1108 assert!(
1109 out.contains("postimage:"),
1110 "expected postimage metadata, got: {out}"
1111 );
1112 }
1113
1114 #[test]
1115 fn detects_toctou_via_preimage_guard() {
1116 let f = make_temp("aaa\n");
1117 let cap = crate::core::limits::max_read_bytes();
1118 let pre = read_preimage(f.path(), cap, false).unwrap();
1119 std::fs::write(f.path(), "bbb\n").unwrap();
1120 let err = ensure_preimage_still_matches(f.path(), &pre.fp, cap).unwrap_err();
1121 assert!(err.contains("TOCTOU guard"), "unexpected error: {err}");
1122 }
1123
1124 #[test]
1128 fn run_io_success_reports_invalidate_effect() {
1129 let f = make_temp("fn main() {\n let x = 42;\n}\n");
1130 let (text, effect) = run_io(
1131 &mk_params(f.path(), "let x = 42", "let x = 99", false, false),
1132 "",
1133 );
1134 assert!(text.contains("✓"), "expected success: {text}");
1135 assert!(
1136 matches!(effect, CacheEffect::Invalidate),
1137 "successful edit must invalidate the cache entry"
1138 );
1139 let content = std::fs::read_to_string(f.path()).unwrap();
1140 assert!(content.contains("let x = 99"));
1141 }
1142
1143 #[test]
1144 fn run_io_failure_reports_no_cache_effect() {
1145 let f = make_temp("some content\n");
1146 let (text, effect) = run_io(&mk_params(f.path(), "nonexistent", "x", false, false), "");
1147 assert!(text.contains("ERROR: old_string not found"));
1148 assert!(
1149 matches!(effect, CacheEffect::None),
1150 "a failed edit must not mutate the cache"
1151 );
1152 }
1153
1154 #[test]
1158 fn run_io_concurrent_edits_to_different_files_all_succeed() {
1159 use std::sync::Arc;
1160 let dir = Arc::new(tempfile::tempdir().unwrap());
1161 let n = 16;
1162 let mut paths = Vec::new();
1163 for i in 0..n {
1164 let p = dir.path().join(format!("file_{i}.txt"));
1165 std::fs::write(&p, format!("value = {i}\n")).unwrap();
1166 paths.push(p);
1167 }
1168 let barrier = Arc::new(std::sync::Barrier::new(n));
1169 let mut handles = Vec::new();
1170 for (i, p) in paths.into_iter().enumerate() {
1171 let barrier = Arc::clone(&barrier);
1172 handles.push(std::thread::spawn(move || {
1173 barrier.wait();
1174 let (text, effect) = run_io(
1175 &mk_params(
1176 &p,
1177 &format!("value = {i}"),
1178 &format!("value = {}", i + 1000),
1179 false,
1180 false,
1181 ),
1182 "",
1183 );
1184 assert!(text.contains("✓"), "edit {i} failed: {text}");
1185 assert!(matches!(effect, CacheEffect::Invalidate));
1186 (p, i)
1187 }));
1188 }
1189 for h in handles {
1190 let (p, i) = h.join().unwrap();
1191 let content = std::fs::read_to_string(&p).unwrap();
1192 assert_eq!(content, format!("value = {}\n", i + 1000));
1193 }
1194 }
1195
1196 #[test]
1197 fn run_io_escalation_reports_store_full_effect() {
1198 let f = make_temp("line a\nline b\nline c\n");
1202 let (text, effect) = run_io(
1203 &mk_params(f.path(), "definitely-not-present", "x", false, false),
1204 "signatures",
1205 );
1206 assert!(
1207 text.contains("[auto-escalation]"),
1208 "expected escalation: {text}"
1209 );
1210 match effect {
1211 CacheEffect::StoreFull(content) => {
1212 assert!(content.contains("line a") && content.contains("line c"));
1213 }
1214 _ => panic!("escalation must report a StoreFull cache effect"),
1215 }
1216 }
1217
1218 #[test]
1219 fn apply_cache_effect_invalidate_and_store() {
1220 let f = make_temp("hello\n");
1221 let mut cache = SessionCache::new();
1222 cache.store(&f.path().to_string_lossy(), "hello\n");
1223 apply_cache_effect(
1224 &mut cache,
1225 &f.path().to_string_lossy(),
1226 CacheEffect::Invalidate,
1227 );
1228 assert!(
1229 cache.get(&f.path().to_string_lossy()).is_none(),
1230 "Invalidate must drop the entry"
1231 );
1232 apply_cache_effect(
1233 &mut cache,
1234 &f.path().to_string_lossy(),
1235 CacheEffect::StoreFull("fresh\n".to_string()),
1236 );
1237 assert!(
1238 cache.get(&f.path().to_string_lossy()).is_some(),
1239 "StoreFull must re-populate the entry"
1240 );
1241 }
1242
1243 #[test]
1244 fn identical_old_new_rejected() {
1245 let f = make_temp("fn main() {}\n");
1246 let mut cache = SessionCache::new();
1247 let result = handle(
1248 &mut cache,
1249 &mk_params(f.path(), "fn main() {}", "fn main() {}", false, false),
1250 );
1251 assert!(result.contains("identical"));
1252 }
1253
1254 #[test]
1255 fn edit_already_applied_detected() {
1256 let f = make_temp("fn updated() {}\n");
1257 let (text, effect) = run_io(
1258 &mk_params(
1259 f.path(),
1260 "fn original() {}",
1261 "fn updated() {}",
1262 false,
1263 false,
1264 ),
1265 "",
1266 );
1267 assert!(text.contains("already exists"));
1268 assert!(text.contains("already applied"));
1269 assert!(matches!(effect, CacheEffect::None));
1270 }
1271
1272 #[test]
1273 fn closest_line_hint_shown() {
1274 let f = make_temp(" fn hello() {\n println!(\"hi\");\n }\n");
1275 let (text, _) = run_io(
1276 &mk_params(f.path(), "fn hello(){", "fn hello_world(){", false, false),
1277 "",
1278 );
1279 assert!(text.contains("Closest match at line"));
1280 }
1281
1282 #[test]
1283 fn missing_file_suggests_relocated_path() {
1284 let dir = tempfile::tempdir().unwrap();
1285 std::fs::create_dir_all(dir.path().join(".git")).unwrap();
1286 std::fs::create_dir_all(dir.path().join("src/new")).unwrap();
1287 std::fs::write(dir.path().join("src/new/gizmo.rs"), "fn gizmo() {}\n").unwrap();
1288
1289 let (text, effect) = run_io(
1290 &mk_params(
1291 &dir.path().join("src/old/gizmo.rs"),
1292 "fn gizmo() {}",
1293 "fn gizmo2() {}",
1294 false,
1295 false,
1296 ),
1297 "",
1298 );
1299 assert!(text.contains("same-named file was found"), "got: {text}");
1300 assert!(text.contains("gizmo.rs"), "got: {text}");
1301 assert!(matches!(effect, CacheEffect::None));
1302 }
1303
1304 #[test]
1305 fn old_string_in_other_file_is_reported() {
1306 let dir = tempfile::tempdir().unwrap();
1307 std::fs::create_dir_all(dir.path().join(".git")).unwrap();
1308 let target = dir.path().join("a.rs");
1309 std::fs::write(&target, "fn unrelated_a() {}\n").unwrap();
1310 std::fs::write(dir.path().join("b.rs"), "fn the_target_symbol() {}\n").unwrap();
1311
1312 let (text, _) = run_io(
1313 &mk_params(
1314 &target,
1315 "fn the_target_symbol() {}",
1316 "fn renamed() {}",
1317 false,
1318 false,
1319 ),
1320 "",
1321 );
1322 assert!(text.contains("matching line exists in"), "got: {text}");
1323 assert!(text.contains("b.rs"), "got: {text}");
1324 }
1325
1326 #[cfg(unix)]
1329 #[test]
1330 fn editing_through_a_symlink_is_rejected() {
1331 let dir = tempfile::tempdir().unwrap();
1332 let real = dir.path().join("real.rs");
1333 std::fs::write(&real, "fn old() {}\n").unwrap();
1334 let link = dir.path().join("link.rs");
1335 std::os::unix::fs::symlink(&real, &link).unwrap();
1336
1337 let (text, effect) = run_io(
1338 &mk_params(&link, "fn old() {}", "fn new() {}", false, false),
1339 "",
1340 );
1341 assert!(text.contains("symlink"), "got: {text}");
1342 assert!(matches!(effect, CacheEffect::None));
1343 assert_eq!(std::fs::read_to_string(&real).unwrap(), "fn old() {}\n");
1345 }
1346
1347 #[cfg(unix)]
1350 #[test]
1351 fn creating_over_a_symlink_is_rejected() {
1352 let dir = tempfile::tempdir().unwrap();
1353 let real = dir.path().join("victim.txt");
1354 std::fs::write(&real, "precious").unwrap();
1355 let link = dir.path().join("innocent.txt");
1356 std::os::unix::fs::symlink(&real, &link).unwrap();
1357
1358 let (text, _) = run_io(&mk_params(&link, "", "overwritten", false, true), "");
1359 assert!(
1360 text.contains("symlink") || text.contains("ERROR"),
1361 "got: {text}"
1362 );
1363 assert_eq!(
1364 std::fs::read_to_string(&real).unwrap(),
1365 "precious",
1366 "symlink target must not be modified"
1367 );
1368 }
1369
1370 #[test]
1371 fn regular_file_edit_still_works_after_symlink_guard() {
1372 let dir = tempfile::tempdir().unwrap();
1373 let file = dir.path().join("normal.rs");
1374 std::fs::write(&file, "fn old() {}\n").unwrap();
1375
1376 let (text, _) = run_io(
1377 &mk_params(&file, "fn old() {}", "fn new() {}", false, false),
1378 "",
1379 );
1380 assert!(
1381 text.contains("Edit applied") || !text.starts_with("ERROR"),
1382 "got: {text}"
1383 );
1384 assert_eq!(std::fs::read_to_string(&file).unwrap(), "fn new() {}\n");
1385 }
1386}