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 crate::core::pathjail::enforce_writable(path)?;
238
239 reject_symlink(path)?;
244
245 if let Some(parent) = path.parent() {
246 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
247 }
248
249 match try_atomic_write(path, bytes, permissions) {
250 Ok(()) => Ok(()),
251 Err(e) if is_readonly_dir_error(&e) && path.is_file() => {
261 in_place_overwrite(path, bytes, permissions).map_err(|fallback_err| {
262 format!(
263 "ERROR: atomic write failed ({e}); in-place fallback also failed: {fallback_err} ({})",
264 path.display()
265 )
266 })
267 }
268 Err(e) => Err(format!("ERROR: atomic write failed: {e} ({})", path.display())),
269 }
270}
271
272fn try_atomic_write(
276 path: &Path,
277 bytes: &[u8],
278 permissions: Option<&std::fs::Permissions>,
279) -> std::io::Result<()> {
280 use std::io::Write;
281
282 let parent = path.parent().ok_or_else(|| {
283 std::io::Error::new(
284 std::io::ErrorKind::InvalidInput,
285 "invalid path (no parent directory)",
286 )
287 })?;
288 let filename = path
289 .file_name()
290 .ok_or_else(|| {
291 std::io::Error::new(
292 std::io::ErrorKind::InvalidInput,
293 "invalid path (no filename)",
294 )
295 })?
296 .to_string_lossy();
297
298 let pid = std::process::id();
299 let nanos = SystemTime::now()
300 .duration_since(UNIX_EPOCH)
301 .map_or(0, |d| d.as_nanos());
302 let tmp = parent.join(format!(".{filename}.lean-ctx.tmp.{pid}.{nanos}"));
303
304 {
305 let mut f = std::fs::OpenOptions::new()
306 .write(true)
307 .create_new(true)
308 .open(&tmp)?;
309 f.write_all(bytes)?;
310 let _ = f.flush();
311 let _ = f.sync_all();
312 }
313
314 if let Some(perms) = permissions {
315 let _ = std::fs::set_permissions(&tmp, perms.clone());
316 }
317
318 #[cfg(windows)]
319 {
320 if path.exists() {
321 let _ = std::fs::remove_file(path);
322 }
323 }
324
325 if let Err(e) = std::fs::rename(&tmp, path) {
326 let _ = std::fs::remove_file(&tmp);
329 return Err(e);
330 }
331 Ok(())
332}
333
334fn in_place_overwrite(
338 path: &Path,
339 bytes: &[u8],
340 permissions: Option<&std::fs::Permissions>,
341) -> std::io::Result<()> {
342 use std::io::Write;
343
344 let mut opts = std::fs::OpenOptions::new();
345 opts.write(true).truncate(true);
346 #[cfg(unix)]
347 {
348 use std::os::unix::fs::OpenOptionsExt;
349 opts.custom_flags(libc::O_NOFOLLOW);
352 }
353
354 let mut f = opts.open(path)?;
355 f.write_all(bytes)?;
356 let _ = f.flush();
357 let _ = f.sync_all();
358
359 if let Some(perms) = permissions {
360 let _ = std::fs::set_permissions(path, perms.clone());
361 }
362 Ok(())
363}
364
365fn is_readonly_dir_error(e: &std::io::Error) -> bool {
369 if e.kind() == std::io::ErrorKind::PermissionDenied {
370 return true;
371 }
372 #[cfg(unix)]
373 {
374 matches!(
375 e.raw_os_error(),
376 Some(libc::EROFS | libc::EACCES | libc::EPERM)
377 )
378 }
379 #[cfg(not(unix))]
380 {
381 false
382 }
383}
384
385fn build_diff_evidence(old: &str, new: &str, label: &str, max_lines: usize) -> String {
386 let diff = similar::TextDiff::from_lines(old, new)
387 .unified_diff()
388 .context_radius(3)
389 .header(label, label)
390 .to_string();
391 let diff = crate::core::redaction::redact_text(&diff);
396
397 let mut out = String::new();
398 for (i, line) in diff.lines().enumerate() {
399 if i >= max_lines {
400 out.push_str(&format!("\n... diff truncated (max_lines={max_lines})"));
401 break;
402 }
403 out.push_str(line);
404 out.push('\n');
405 }
406 out.trim_end_matches('\n').to_string()
407}
408
409pub enum CacheEffect {
416 None,
418 Invalidate,
420 StoreFull(String),
423}
424
425pub fn handle(cache: &mut SessionCache, params: &EditParams) -> String {
430 let last_mode = cache
431 .get(¶ms.path)
432 .map(|e| e.last_mode.clone())
433 .unwrap_or_default();
434 let (text, effect) = run_io(params, &last_mode);
435 record_outcome(params, &last_mode, &text, &effect);
436 apply_cache_effect(cache, ¶ms.path, effect);
437 text
438}
439
440pub fn record_outcome(params: &EditParams, last_mode: &str, text: &str, effect: &CacheEffect) {
447 if params.create {
448 return;
449 }
450 let success = matches!(effect, CacheEffect::Invalidate);
451 let not_found_failure = matches!(effect, CacheEffect::StoreFull(_))
452 || (matches!(effect, CacheEffect::None)
453 && text.starts_with("ERROR: old_string not found")
454 && !text.contains("already"));
455 if success || not_found_failure {
456 crate::core::edit_quality::record_edit_outcome(¶ms.path, last_mode, success);
457 }
458}
459
460pub fn apply_cache_effect(cache: &mut SessionCache, path: &str, effect: CacheEffect) {
462 match effect {
463 CacheEffect::None => {}
464 CacheEffect::Invalidate => {
465 cache.invalidate(path);
466 }
467 CacheEffect::StoreFull(content) => {
468 cache.store(path, &content);
469 cache.mark_full_delivered(path);
470 }
471 }
472}
473
474pub fn run_io(params: &EditParams, last_mode: &str) -> (String, CacheEffect) {
480 let file_path = ¶ms.path;
481
482 if params.create {
483 return handle_create(file_path, ¶ms.new_string, params);
484 }
485
486 let cap = crate::core::limits::max_read_bytes();
487 let path = Path::new(file_path);
488 let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
489 Ok(p) => p,
490 Err(e) => {
491 if !path.exists() {
494 let hint = crate::tools::edit_recovery::moved_or_deleted_hint(path);
495 return (format!("{e}{hint}"), CacheEffect::None);
496 }
497 return (e, CacheEffect::None);
498 }
499 };
500 if let Err(e) = verify_expected_preimage(&pre, params) {
501 return (e, CacheEffect::None);
502 }
503 let content = &pre.text;
504
505 if params.old_string.is_empty() {
506 return (
507 "ERROR: old_string must not be empty (use create=true to create a new file)".into(),
508 CacheEffect::None,
509 );
510 }
511
512 if params.old_string == params.new_string {
513 return (
514 "ERROR: old_string and new_string are identical — nothing to change.".into(),
515 CacheEffect::None,
516 );
517 }
518
519 let uses_crlf = pre.uses_crlf;
520 let old_str = ¶ms.old_string;
521 let new_str = ¶ms.new_string;
522
523 let occurrences = content.matches(old_str).count();
524
525 if occurrences > 0 {
526 let args = ReplaceArgs {
527 content,
528 old_str,
529 new_str,
530 occurrences,
531 replace_all: params.replace_all,
532 old_tokens: count_tokens(¶ms.old_string),
533 new_tokens: count_tokens(¶ms.new_string),
534 };
535 return do_replace(path, &pre, params, cap, &args);
536 }
537
538 if uses_crlf && !old_str.contains('\r') {
539 let old_crlf = old_str.replace('\n', "\r\n");
540 let occ = content.matches(&old_crlf).count();
541 if occ > 0 {
542 let new_crlf = new_str.replace('\n', "\r\n");
543 let args = ReplaceArgs {
544 content,
545 old_str: &old_crlf,
546 new_str: &new_crlf,
547 occurrences: occ,
548 replace_all: params.replace_all,
549 old_tokens: count_tokens(¶ms.old_string),
550 new_tokens: count_tokens(¶ms.new_string),
551 };
552 return do_replace(path, &pre, params, cap, &args);
553 }
554 } else if !uses_crlf && old_str.contains("\r\n") {
555 let old_lf = old_str.replace("\r\n", "\n");
556 let occ = content.matches(&old_lf).count();
557 if occ > 0 {
558 let new_lf = new_str.replace("\r\n", "\n");
559 let args = ReplaceArgs {
560 content,
561 old_str: &old_lf,
562 new_str: &new_lf,
563 occurrences: occ,
564 replace_all: params.replace_all,
565 old_tokens: count_tokens(¶ms.old_string),
566 new_tokens: count_tokens(¶ms.new_string),
567 };
568 return do_replace(path, &pre, params, cap, &args);
569 }
570 }
571
572 let normalized_content = trim_trailing_per_line(content);
573 let normalized_old = trim_trailing_per_line(old_str);
574 if !normalized_old.is_empty() && normalized_content.contains(&normalized_old) {
575 let line_sep = if uses_crlf { "\r\n" } else { "\n" };
576 let adapted_new = adapt_new_string_to_line_sep(new_str, line_sep);
577 let adapted_old = find_original_span(content, &normalized_old);
578 if let Some(original_match) = adapted_old {
579 let occ = content.matches(&original_match).count();
580 let args = ReplaceArgs {
581 content,
582 old_str: &original_match,
583 new_str: &adapted_new,
584 occurrences: occ,
585 replace_all: params.replace_all,
586 old_tokens: count_tokens(¶ms.old_string),
587 new_tokens: count_tokens(¶ms.new_string),
588 };
589 return do_replace(path, &pre, params, cap, &args);
590 }
591 }
592
593 if content.contains(new_str) {
594 return (
595 format!(
596 "ERROR: old_string not found in {file_path}, but new_string already exists in the file. \
597 The edit was likely already applied (by a previous tool call or another agent)."
598 ),
599 CacheEffect::None,
600 );
601 }
602
603 let preview = if old_str.len() > 80 {
604 format!("{}...", &old_str[..old_str.floor_char_boundary(77)])
605 } else {
606 old_str.clone()
607 };
608 let hint = if uses_crlf {
609 " (file uses CRLF line endings)"
610 } else {
611 ""
612 };
613
614 let closest_hint = find_closest_line_hint(content, old_str);
615 let cross_file = crate::tools::edit_recovery::cross_file_hint(path, old_str);
616
617 let (escalation, effect) = auto_escalate_reread(last_mode, file_path);
618
619 (
620 format!(
621 "ERROR: old_string not found in {file_path}{hint}. \
622 Make sure it matches exactly (including whitespace/indentation).\n\
623 Searched for: {preview}{closest_hint}{cross_file}{escalation}"
624 ),
625 effect,
626 )
627}
628
629fn find_closest_line_hint(content: &str, old_str: &str) -> String {
632 let first_line = old_str.lines().next().unwrap_or("").trim();
633 if first_line.len() < 4 {
634 return String::new();
635 }
636
637 let mut best_line: Option<(usize, &str)> = None;
638
639 for (i, line) in content.lines().enumerate() {
640 if line.contains(first_line) {
641 best_line = Some((i + 1, line));
642 break;
643 }
644 }
645
646 if best_line.is_none() {
648 let keyword = first_line
649 .split(|c: char| !c.is_alphanumeric() && c != '_')
650 .find(|w| w.len() >= 4);
651
652 if let Some(keyword) = keyword {
653 for (i, line) in content.lines().enumerate() {
654 if line.contains(keyword) {
655 best_line = Some((i + 1, line));
656 break;
657 }
658 }
659 }
660 }
661
662 match best_line {
663 Some((line_num, line_content)) => {
664 let trimmed = line_content.trim();
665 let preview = if trimmed.len() > 100 {
666 format!("{}...", &trimmed[..trimmed.floor_char_boundary(97)])
667 } else {
668 trimmed.to_string()
669 };
670 format!(
671 "\nClosest match at line {line_num}: `{preview}`\n\
672 Hint: check indentation/whitespace differences."
673 )
674 }
675 None => String::new(),
676 }
677}
678
679fn auto_escalate_reread(last_mode: &str, path: &str) -> (String, CacheEffect) {
684 if last_mode.is_empty() || last_mode == "full" {
685 return (String::new(), CacheEffect::None);
686 }
687
688 let Ok(fresh_content) = std::fs::read_to_string(path) else {
689 return (String::new(), CacheEffect::None);
690 };
691
692 let line_count = fresh_content.lines().count();
693 const MAX_LINES: usize = 300;
694
695 let content_preview = if line_count <= MAX_LINES {
696 fresh_content.clone()
697 } else {
698 let lines: Vec<&str> = fresh_content.lines().collect();
699 let head = &lines[..MAX_LINES / 2];
700 let tail = &lines[line_count - MAX_LINES / 2..];
701 let omitted = line_count - MAX_LINES;
702 format!(
703 "{}\n[... {omitted} lines omitted ...]\n{}",
704 head.join("\n"),
705 tail.join("\n")
706 )
707 };
708
709 (
710 format!(
711 "\n\n[auto-escalation] Last read used mode=\"{last_mode}\". \
712 Full content ({line_count}L) below — retry edit with exact text from here:\n\n{content_preview}"
713 ),
714 CacheEffect::StoreFull(fresh_content),
715 )
716}
717
718fn do_replace(
719 path: &Path,
720 pre: &FilePreimage,
721 params: &EditParams,
722 cap: usize,
723 args: &ReplaceArgs<'_>,
724) -> (String, CacheEffect) {
725 if args.occurrences > 1 && !args.replace_all {
726 return (
727 format!(
728 "ERROR: old_string found {} times in {}. \
729 Use replace_all=true to replace all, or provide more context to make old_string unique.",
730 args.occurrences,
731 path.display()
732 ),
733 CacheEffect::None,
734 );
735 }
736
737 let new_content = if args.replace_all {
738 args.content.replace(args.old_str, args.new_str)
739 } else {
740 args.content.replacen(args.old_str, args.new_str, 1)
741 };
742
743 if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
744 return (e, CacheEffect::None);
745 }
746
747 let backup_path = if params.backup {
748 let bp = params
749 .backup_path
750 .as_deref()
751 .map(PathBuf::from)
752 .or_else(|| default_backup_path(path));
753 let Some(bp) = bp else {
754 return (
755 format!("ERROR: cannot compute backup path for {}", path.display()),
756 CacheEffect::None,
757 );
758 };
759 if let Err(e) = write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
760 {
761 return (
762 format!("ERROR: cannot create backup {}: {e}", bp.display()),
763 CacheEffect::None,
764 );
765 }
766 Some(bp.to_string_lossy().to_string())
767 } else {
768 None
769 };
770
771 if let Err(e) =
772 write_atomic_bytes_with_permissions(path, new_content.as_bytes(), Some(&pre.permissions))
773 {
774 return (e, CacheEffect::None);
775 }
776
777 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
778 bt.record_edit(¶ms.path);
779 }
780
781 let old_lines = args.content.lines().count();
782 let new_lines = new_content.lines().count();
783 let line_delta = new_lines as i64 - old_lines as i64;
784 let delta_str = if line_delta > 0 {
785 format!("+{line_delta}")
786 } else {
787 format!("{line_delta}")
788 };
789
790 let old_tokens = args.old_tokens;
791 let new_tokens = args.new_tokens;
792
793 let replaced_str = if args.replace_all && args.occurrences > 1 {
794 format!("{} replacements", args.occurrences)
795 } else {
796 "1 replacement".into()
797 };
798
799 let short = path.file_name().map_or_else(
800 || path.to_string_lossy().to_string(),
801 |f| f.to_string_lossy().to_string(),
802 );
803
804 let post_mtime_ms = std::fs::metadata(path)
805 .ok()
806 .and_then(|m| m.modified().ok())
807 .map_or(0, system_time_to_millis);
808 let post_fp = FileFingerprint {
809 size: new_content.len() as u64,
810 mtime_ms: post_mtime_ms,
811 md5: crate::core::hasher::hash_hex(new_content.as_bytes()),
812 };
813
814 let mut out = format!(
815 "✓ {short}: {replaced_str}, {delta_str} lines ({old_tokens}→{new_tokens} tok)\n\
816preimage: bytes={}, mtime_ms={}, md5={}\n\
817postimage: bytes={}, mtime_ms={}, md5={}",
818 pre.fp.size, pre.fp.mtime_ms, pre.fp.md5, post_fp.size, post_fp.mtime_ms, post_fp.md5
819 );
820 if let Some(bp) = backup_path {
821 out.push_str(&format!("\nbackup: {bp}"));
822 }
823 if params.evidence {
824 let diff = build_diff_evidence(args.content, &new_content, &short, params.diff_max_lines);
825 out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
826 out.push_str(&diff);
827 out.push_str("\n```");
828 }
829 (out, CacheEffect::Invalidate)
830}
831
832fn handle_create(file_path: &str, content: &str, params: &EditParams) -> (String, CacheEffect) {
833 let path = Path::new(file_path);
834 let cap = crate::core::limits::max_read_bytes();
835
836 if let Err(e) = crate::core::pathjail::enforce_writable(path) {
840 return (format!("ERROR: {e}"), CacheEffect::None);
841 }
842
843 let mut preimage: Option<FilePreimage> = None;
844 if path.exists() {
845 let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
846 Ok(p) => p,
847 Err(e) => return (e, CacheEffect::None),
848 };
849 if let Err(e) = verify_expected_preimage(&pre, params) {
850 return (e, CacheEffect::None);
851 }
852 if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
853 return (e, CacheEffect::None);
854 }
855 preimage = Some(pre);
856 }
857
858 if let Some(parent) = path.parent()
859 && !parent.exists()
860 && let Err(e) = std::fs::create_dir_all(parent)
861 {
862 return (
863 format!("ERROR: cannot create directory {}: {e}", parent.display()),
864 CacheEffect::None,
865 );
866 }
867
868 let backup_path = if params.backup {
869 if let Some(pre) = &preimage {
870 let bp = params
871 .backup_path
872 .as_deref()
873 .map(PathBuf::from)
874 .or_else(|| default_backup_path(path));
875 let Some(bp) = bp else {
876 return (
877 format!("ERROR: cannot compute backup path for {}", path.display()),
878 CacheEffect::None,
879 );
880 };
881 if let Err(e) =
882 write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
883 {
884 return (
885 format!("ERROR: cannot create backup {}: {e}", bp.display()),
886 CacheEffect::None,
887 );
888 }
889 Some(bp.to_string_lossy().to_string())
890 } else {
891 None
892 }
893 } else {
894 None
895 };
896
897 let perms = preimage.as_ref().map(|p| &p.permissions);
898 if let Err(e) = write_atomic_bytes_with_permissions(path, content.as_bytes(), perms) {
899 return (e, CacheEffect::None);
900 }
901
902 let lines = content.lines().count();
903 let tokens = count_tokens(content);
904 let short = path.file_name().map_or_else(
905 || path.to_string_lossy().to_string(),
906 |f| f.to_string_lossy().to_string(),
907 );
908
909 let mut out = format!("✓ created {short}: {lines} lines, {tokens} tok");
910 if let Some(bp) = backup_path {
911 out.push_str(&format!("\nbackup: {bp}"));
912 }
913 (out, CacheEffect::Invalidate)
914}
915
916fn trim_trailing_per_line(s: &str) -> String {
917 s.lines().map(str::trim_end).collect::<Vec<_>>().join("\n")
918}
919
920fn adapt_new_string_to_line_sep(s: &str, sep: &str) -> String {
921 let normalized = s.replace("\r\n", "\n");
922 if sep == "\r\n" {
923 normalized.replace('\n', "\r\n")
924 } else {
925 normalized
926 }
927}
928
929fn find_original_span(content: &str, normalized_needle: &str) -> Option<String> {
932 let needle_lines: Vec<&str> = normalized_needle.lines().collect();
933 if needle_lines.is_empty() {
934 return None;
935 }
936
937 let content_lines: Vec<&str> = content.lines().collect();
938
939 'outer: for start in 0..content_lines.len() {
940 if start + needle_lines.len() > content_lines.len() {
941 break;
942 }
943 for (i, nl) in needle_lines.iter().enumerate() {
944 if content_lines[start + i].trim_end() != *nl {
945 continue 'outer;
946 }
947 }
948 let sep = if content.contains("\r\n") {
949 "\r\n"
950 } else {
951 "\n"
952 };
953 return Some(content_lines[start..start + needle_lines.len()].join(sep));
954 }
955 None
956}
957
958#[cfg(test)]
959mod tests {
960 use super::*;
961 use std::io::Write;
962 use tempfile::NamedTempFile;
963
964 fn make_temp(content: &str) -> NamedTempFile {
965 let mut f = NamedTempFile::new().unwrap();
966 f.write_all(content.as_bytes()).unwrap();
967 f
968 }
969
970 fn mk_params(path: &Path, old: &str, new: &str, replace_all: bool, create: bool) -> EditParams {
971 EditParams {
972 path: path.to_string_lossy().to_string(),
973 old_string: old.to_string(),
974 new_string: new.to_string(),
975 replace_all,
976 create,
977 expected_md5: None,
978 expected_size: None,
979 expected_mtime_ms: None,
980 backup: false,
981 backup_path: None,
982 evidence: false,
983 diff_max_lines: 200,
984 allow_lossy_utf8: false,
985 }
986 }
987
988 #[test]
989 fn replace_single_occurrence() {
990 let f = make_temp("fn hello() {\n println!(\"hello\");\n}\n");
991 let mut cache = SessionCache::new();
992 let result = handle(
993 &mut cache,
994 &mk_params(f.path(), "hello", "world", false, false),
995 );
996 assert!(result.contains("ERROR"), "should fail: 'hello' appears 2x");
997 }
998
999 #[test]
1000 fn replace_all() {
1001 let f = make_temp("aaa bbb aaa\n");
1002 let mut cache = SessionCache::new();
1003 let result = handle(&mut cache, &mk_params(f.path(), "aaa", "ccc", true, false));
1004 assert!(result.contains("2 replacements"));
1005 let content = std::fs::read_to_string(f.path()).unwrap();
1006 assert_eq!(content, "ccc bbb ccc\n");
1007 }
1008
1009 #[test]
1010 fn not_found_error() {
1011 let f = make_temp("some content\n");
1012 let mut cache = SessionCache::new();
1013 let result = handle(
1014 &mut cache,
1015 &mk_params(f.path(), "nonexistent", "x", false, false),
1016 );
1017 assert!(result.contains("ERROR: old_string not found"));
1018 }
1019
1020 #[test]
1021 fn create_new_file() {
1022 let dir = tempfile::tempdir().unwrap();
1023 let path = dir.path().join("sub/new_file.txt");
1024 let mut cache = SessionCache::new();
1025 let result = handle(
1026 &mut cache,
1027 &mk_params(&path, "", "line1\nline2\nline3\n", false, true),
1028 );
1029 assert!(result.contains("created new_file.txt"));
1030 assert!(result.contains("3 lines"));
1031 assert!(path.exists());
1032 }
1033
1034 #[cfg(not(feature = "no-jail"))]
1037 #[test]
1038 fn create_denied_in_read_only_root() {
1039 let _iso = crate::core::data_dir::isolated_data_dir();
1040 let dir = tempfile::tempdir().unwrap();
1041 let ro = dir.path().join("refrepo");
1042 std::fs::create_dir_all(&ro).unwrap();
1043 let path = ro.join("sub/new_file.txt");
1044
1045 let ro_canon = crate::core::pathjail::canonicalize_or_self(&ro);
1046 crate::test_env::set_var(
1047 "LEAN_CTX_READ_ONLY_ROOTS",
1048 ro_canon.to_string_lossy().as_ref(),
1049 );
1050 let mut cache = SessionCache::new();
1051 let result = handle(&mut cache, &mk_params(&path, "", "x\n", false, true));
1052 crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
1053
1054 assert!(
1055 result.contains("read-only"),
1056 "create in a read-only root must be refused: {result}"
1057 );
1058 assert!(!path.exists(), "no file may be created in a read-only root");
1059 assert!(
1060 !ro.join("sub").exists(),
1061 "no directory may be created in a read-only root"
1062 );
1063 }
1064
1065 #[cfg(not(feature = "no-jail"))]
1069 #[test]
1070 fn edit_denied_in_read_only_root() {
1071 let _iso = crate::core::data_dir::isolated_data_dir();
1072 let dir = tempfile::tempdir().unwrap();
1073 let ro = dir.path().join("refrepo");
1074 std::fs::create_dir_all(&ro).unwrap();
1075 let path = ro.join("a.txt");
1076 std::fs::write(&path, "alpha beta\n").unwrap();
1077
1078 let ro_canon = crate::core::pathjail::canonicalize_or_self(&ro);
1079 crate::test_env::set_var(
1080 "LEAN_CTX_READ_ONLY_ROOTS",
1081 ro_canon.to_string_lossy().as_ref(),
1082 );
1083 let mut cache = SessionCache::new();
1084 let result = handle(
1085 &mut cache,
1086 &mk_params(&path, "alpha", "OMEGA", false, false),
1087 );
1088 crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
1089
1090 assert!(
1091 result.contains("read-only"),
1092 "edit in a read-only root must be refused: {result}"
1093 );
1094 assert_eq!(
1095 std::fs::read_to_string(&path).unwrap(),
1096 "alpha beta\n",
1097 "the file must be left untouched"
1098 );
1099 }
1100
1101 #[cfg(not(feature = "no-jail"))]
1107 #[test]
1108 fn backup_path_cannot_smuggle_writes_into_read_only_root() {
1109 let _iso = crate::core::data_dir::isolated_data_dir();
1110 let dir = tempfile::tempdir().unwrap();
1111 let ro = dir.path().join("refrepo");
1112 let work = dir.path().join("work");
1113 std::fs::create_dir_all(&ro).unwrap();
1114 std::fs::create_dir_all(&work).unwrap();
1115 let target = work.join("a.txt"); std::fs::write(&target, "alpha beta\n").unwrap();
1117 let smuggled = ro.join("leak.bak"); let ro_canon = crate::core::pathjail::canonicalize_or_self(&ro);
1120 crate::test_env::set_var(
1121 "LEAN_CTX_READ_ONLY_ROOTS",
1122 ro_canon.to_string_lossy().as_ref(),
1123 );
1124 let mut params = mk_params(&target, "alpha", "OMEGA", false, false);
1125 params.backup = true;
1126 params.backup_path = Some(smuggled.to_string_lossy().to_string());
1127 let mut cache = SessionCache::new();
1128 let result = handle(&mut cache, ¶ms);
1129 crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
1130
1131 assert!(
1132 result.contains("read-only"),
1133 "a backup_path into a read-only root must be refused: {result}"
1134 );
1135 assert!(
1136 !smuggled.exists(),
1137 "no backup may be smuggled into a read-only root"
1138 );
1139 assert_eq!(
1140 std::fs::read_to_string(&target).unwrap(),
1141 "alpha beta\n",
1142 "fail-closed: the writable target must be untouched when the backup is denied"
1143 );
1144 }
1145
1146 #[cfg(not(feature = "no-jail"))]
1151 #[test]
1152 fn edit_denied_via_config_read_only_roots() {
1153 let _iso = crate::core::data_dir::isolated_data_dir();
1154 let dir = tempfile::tempdir().unwrap();
1155 let ro = dir.path().join("refrepo");
1156 std::fs::create_dir_all(&ro).unwrap();
1157 let path = ro.join("a.txt");
1158 std::fs::write(&path, "alpha beta\n").unwrap();
1159
1160 let cfg_path = crate::core::config::Config::path().unwrap();
1162 if let Some(parent) = cfg_path.parent() {
1163 std::fs::create_dir_all(parent).unwrap();
1164 }
1165 std::fs::write(
1167 &cfg_path,
1168 format!("read_only_roots = ['{}']\n", ro.to_string_lossy()),
1169 )
1170 .unwrap();
1171
1172 let mut cache = SessionCache::new();
1173 let result = handle(
1174 &mut cache,
1175 &mk_params(&path, "alpha", "OMEGA", false, false),
1176 );
1177
1178 assert!(
1179 result.contains("read-only"),
1180 "config-declared read_only_roots must deny the edit: {result}"
1181 );
1182 assert_eq!(
1183 std::fs::read_to_string(&path).unwrap(),
1184 "alpha beta\n",
1185 "the file must be left untouched"
1186 );
1187 }
1188
1189 #[test]
1190 fn readonly_dir_error_classification() {
1191 assert!(is_readonly_dir_error(&std::io::Error::from(
1192 std::io::ErrorKind::PermissionDenied
1193 )));
1194 assert!(!is_readonly_dir_error(&std::io::Error::from(
1195 std::io::ErrorKind::NotFound
1196 )));
1197 #[cfg(unix)]
1198 {
1199 assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
1200 libc::EROFS
1201 )));
1202 assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
1203 libc::EACCES
1204 )));
1205 assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
1206 libc::EPERM
1207 )));
1208 }
1209 }
1210
1211 #[cfg(unix)]
1212 #[test]
1213 fn in_place_overwrite_truncates_existing_file() {
1214 let dir = tempfile::tempdir().unwrap();
1215 let path = dir.path().join("config.jsonc");
1216 std::fs::write(&path, b"longer original content").unwrap();
1217 in_place_overwrite(&path, b"short", None).unwrap();
1218 assert_eq!(std::fs::read(&path).unwrap(), b"short");
1219 }
1220
1221 #[cfg(unix)]
1228 #[test]
1229 fn write_falls_back_on_readonly_parent_dir() {
1230 use std::os::unix::fs::PermissionsExt;
1231
1232 if unsafe { libc::geteuid() } == 0 {
1234 return;
1235 }
1236
1237 let dir = tempfile::tempdir().unwrap();
1238 let path = dir.path().join("opencode.jsonc");
1239 std::fs::write(&path, b"hello").unwrap();
1240
1241 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap();
1244
1245 let res = write_atomic_bytes_with_permissions(&path, b"world", None);
1246
1247 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
1249
1250 assert!(res.is_ok(), "in-place fallback should succeed: {res:?}");
1251 assert_eq!(std::fs::read(&path).unwrap(), b"world");
1252 }
1253
1254 #[cfg(unix)]
1257 #[test]
1258 fn handle_edit_succeeds_on_readonly_parent_dir() {
1259 use std::os::unix::fs::PermissionsExt;
1260
1261 if unsafe { libc::geteuid() } == 0 {
1263 return;
1264 }
1265
1266 let dir = tempfile::tempdir().unwrap();
1267 let path = dir.path().join("opencode.jsonc");
1268 std::fs::write(&path, "hello world\n").unwrap();
1269 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap();
1270
1271 let mut cache = SessionCache::new();
1272 let result = handle(
1273 &mut cache,
1274 &mk_params(&path, "hello", "goodbye", false, false),
1275 );
1276
1277 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
1278
1279 assert!(
1280 result.contains('✓'),
1281 "edit should succeed via in-place fallback: {result}"
1282 );
1283 assert_eq!(std::fs::read_to_string(&path).unwrap(), "goodbye world\n");
1284 }
1285
1286 #[test]
1287 fn unique_match_succeeds() {
1288 let f = make_temp("fn main() {\n let x = 42;\n}\n");
1289 let mut cache = SessionCache::new();
1290 let result = handle(
1291 &mut cache,
1292 &mk_params(f.path(), "let x = 42", "let x = 99", false, false),
1293 );
1294 assert!(result.contains("✓"));
1295 assert!(result.contains("1 replacement"));
1296 let content = std::fs::read_to_string(f.path()).unwrap();
1297 assert!(content.contains("let x = 99"));
1298 }
1299
1300 #[test]
1301 fn crlf_file_with_lf_search() {
1302 let f = make_temp("line1\r\nline2\r\nline3\r\n");
1303 let mut cache = SessionCache::new();
1304 let result = handle(
1305 &mut cache,
1306 &mk_params(f.path(), "line1\nline2", "changed1\nchanged2", false, false),
1307 );
1308 assert!(result.contains("✓"), "CRLF fallback should work: {result}");
1309 let content = std::fs::read_to_string(f.path()).unwrap();
1310 assert!(
1311 content.contains("changed1\r\nchanged2"),
1312 "new_string should be adapted to CRLF: {content:?}"
1313 );
1314 assert!(
1315 content.contains("\r\nline3\r\n"),
1316 "rest of file should keep CRLF: {content:?}"
1317 );
1318 }
1319
1320 #[test]
1321 fn lf_file_with_crlf_search() {
1322 let f = make_temp("line1\nline2\nline3\n");
1323 let mut cache = SessionCache::new();
1324 let result = handle(
1325 &mut cache,
1326 &mk_params(f.path(), "line1\r\nline2", "a\r\nb", false, false),
1327 );
1328 assert!(result.contains("✓"), "LF fallback should work: {result}");
1329 let content = std::fs::read_to_string(f.path()).unwrap();
1330 assert!(
1331 content.contains("a\nb"),
1332 "new_string should be adapted to LF: {content:?}"
1333 );
1334 }
1335
1336 #[test]
1337 fn trailing_whitespace_tolerance() {
1338 let f = make_temp(" let x = 1; \n let y = 2;\n");
1339 let mut cache = SessionCache::new();
1340 let result = handle(
1341 &mut cache,
1342 &mk_params(
1343 f.path(),
1344 " let x = 1;\n let y = 2;",
1345 " let x = 10;\n let y = 20;",
1346 false,
1347 false,
1348 ),
1349 );
1350 assert!(
1351 result.contains("✓"),
1352 "trailing whitespace tolerance should work: {result}"
1353 );
1354 let content = std::fs::read_to_string(f.path()).unwrap();
1355 assert!(content.contains("let x = 10;"));
1356 assert!(content.contains("let y = 20;"));
1357 }
1358
1359 #[test]
1360 fn crlf_with_trailing_whitespace() {
1361 let f = make_temp(" const a = 1; \r\n const b = 2;\r\n");
1362 let mut cache = SessionCache::new();
1363 let result = handle(
1364 &mut cache,
1365 &mk_params(
1366 f.path(),
1367 " const a = 1;\n const b = 2;",
1368 " const a = 10;\n const b = 20;",
1369 false,
1370 false,
1371 ),
1372 );
1373 assert!(
1374 result.contains("✓"),
1375 "CRLF + trailing whitespace should work: {result}"
1376 );
1377 let content = std::fs::read_to_string(f.path()).unwrap();
1378 assert!(content.contains("const a = 10;"));
1379 assert!(content.contains("const b = 20;"));
1380 }
1381
1382 #[test]
1383 fn rejects_invalid_utf8_by_default() {
1384 let mut f = NamedTempFile::new().unwrap();
1385 f.write_all(&[0xff, 0xfe, 0xfd]).unwrap();
1386 let mut cache = SessionCache::new();
1387 let result = handle(&mut cache, &mk_params(f.path(), "a", "b", false, false));
1388 assert!(
1389 result.contains("not valid UTF-8"),
1390 "expected utf8 rejection, got: {result}"
1391 );
1392 }
1393
1394 #[test]
1395 fn allows_lossy_utf8_only_when_enabled() {
1396 let mut f = NamedTempFile::new().unwrap();
1397 f.write_all(&[0xff, 0xfe, 0xfd]).unwrap();
1398 let mut cache = SessionCache::new();
1399 let mut p = mk_params(f.path(), "a", "b", false, false);
1400 p.allow_lossy_utf8 = true;
1401 let result = handle(&mut cache, &p);
1402 assert!(
1403 !result.contains("not valid UTF-8"),
1404 "lossy mode should avoid utf8 hard error, got: {result}"
1405 );
1406 }
1407
1408 #[test]
1409 fn expected_md5_mismatch_fails_without_writing() {
1410 let f = make_temp("aaa\n");
1411 let mut cache = SessionCache::new();
1412 let mut p = mk_params(f.path(), "aaa", "bbb", false, false);
1413 p.expected_md5 = Some("deadbeef".to_string());
1414 let result = handle(&mut cache, &p);
1415 assert!(
1416 result.contains("preimage mismatch"),
1417 "expected preimage mismatch, got: {result}"
1418 );
1419 let content = std::fs::read_to_string(f.path()).unwrap();
1420 assert_eq!(content, "aaa\n");
1421 }
1422
1423 #[test]
1424 fn backup_is_created_when_enabled() {
1425 let f = make_temp("aaa\n");
1426 let mut cache = SessionCache::new();
1427 let mut p = mk_params(f.path(), "aaa", "bbb", false, false);
1428 p.backup = true;
1429 let out = handle(&mut cache, &p);
1430 assert!(out.contains("backup:"), "expected backup path, got: {out}");
1431 let bp = out
1432 .lines()
1433 .find_map(|l| l.strip_prefix("backup: "))
1434 .expect("backup line");
1435 let backup_content = std::fs::read_to_string(bp).unwrap();
1436 assert_eq!(backup_content, "aaa\n");
1437 let content = std::fs::read_to_string(f.path()).unwrap();
1438 assert_eq!(content, "bbb\n");
1439 }
1440
1441 #[test]
1442 fn evidence_diff_is_emitted_when_enabled() {
1443 let f = make_temp("line1\nline2\n");
1444 let mut cache = SessionCache::new();
1445 let mut p = mk_params(f.path(), "line2", "changed2", false, false);
1446 p.evidence = true;
1447 p.diff_max_lines = 50;
1448 let out = handle(&mut cache, &p);
1449 assert!(out.contains("```diff"), "expected diff fence, got: {out}");
1450 assert!(
1451 out.contains("preimage:"),
1452 "expected preimage metadata, got: {out}"
1453 );
1454 assert!(
1455 out.contains("postimage:"),
1456 "expected postimage metadata, got: {out}"
1457 );
1458 }
1459
1460 #[test]
1461 fn detects_toctou_via_preimage_guard() {
1462 let f = make_temp("aaa\n");
1463 let cap = crate::core::limits::max_read_bytes();
1464 let pre = read_preimage(f.path(), cap, false).unwrap();
1465 std::fs::write(f.path(), "bbb\n").unwrap();
1466 let err = ensure_preimage_still_matches(f.path(), &pre.fp, cap).unwrap_err();
1467 assert!(err.contains("TOCTOU guard"), "unexpected error: {err}");
1468 }
1469
1470 #[test]
1474 fn run_io_success_reports_invalidate_effect() {
1475 let f = make_temp("fn main() {\n let x = 42;\n}\n");
1476 let (text, effect) = run_io(
1477 &mk_params(f.path(), "let x = 42", "let x = 99", false, false),
1478 "",
1479 );
1480 assert!(text.contains("✓"), "expected success: {text}");
1481 assert!(
1482 matches!(effect, CacheEffect::Invalidate),
1483 "successful edit must invalidate the cache entry"
1484 );
1485 let content = std::fs::read_to_string(f.path()).unwrap();
1486 assert!(content.contains("let x = 99"));
1487 }
1488
1489 #[test]
1490 fn run_io_failure_reports_no_cache_effect() {
1491 let f = make_temp("some content\n");
1492 let (text, effect) = run_io(&mk_params(f.path(), "nonexistent", "x", false, false), "");
1493 assert!(text.contains("ERROR: old_string not found"));
1494 assert!(
1495 matches!(effect, CacheEffect::None),
1496 "a failed edit must not mutate the cache"
1497 );
1498 }
1499
1500 #[test]
1504 fn run_io_concurrent_edits_to_different_files_all_succeed() {
1505 use std::sync::Arc;
1506 let dir = Arc::new(tempfile::tempdir().unwrap());
1507 let n = 16;
1508 let mut paths = Vec::new();
1509 for i in 0..n {
1510 let p = dir.path().join(format!("file_{i}.txt"));
1511 std::fs::write(&p, format!("value = {i}\n")).unwrap();
1512 paths.push(p);
1513 }
1514 let barrier = Arc::new(std::sync::Barrier::new(n));
1515 let mut handles = Vec::new();
1516 for (i, p) in paths.into_iter().enumerate() {
1517 let barrier = Arc::clone(&barrier);
1518 handles.push(std::thread::spawn(move || {
1519 barrier.wait();
1520 let (text, effect) = run_io(
1521 &mk_params(
1522 &p,
1523 &format!("value = {i}"),
1524 &format!("value = {}", i + 1000),
1525 false,
1526 false,
1527 ),
1528 "",
1529 );
1530 assert!(text.contains("✓"), "edit {i} failed: {text}");
1531 assert!(matches!(effect, CacheEffect::Invalidate));
1532 (p, i)
1533 }));
1534 }
1535 for h in handles {
1536 let (p, i) = h.join().unwrap();
1537 let content = std::fs::read_to_string(&p).unwrap();
1538 assert_eq!(content, format!("value = {}\n", i + 1000));
1539 }
1540 }
1541
1542 #[test]
1543 fn run_io_escalation_reports_store_full_effect() {
1544 let f = make_temp("line a\nline b\nline c\n");
1548 let (text, effect) = run_io(
1549 &mk_params(f.path(), "definitely-not-present", "x", false, false),
1550 "signatures",
1551 );
1552 assert!(
1553 text.contains("[auto-escalation]"),
1554 "expected escalation: {text}"
1555 );
1556 match effect {
1557 CacheEffect::StoreFull(content) => {
1558 assert!(content.contains("line a") && content.contains("line c"));
1559 }
1560 _ => panic!("escalation must report a StoreFull cache effect"),
1561 }
1562 }
1563
1564 #[test]
1565 fn apply_cache_effect_invalidate_and_store() {
1566 let f = make_temp("hello\n");
1567 let mut cache = SessionCache::new();
1568 cache.store(&f.path().to_string_lossy(), "hello\n");
1569 apply_cache_effect(
1570 &mut cache,
1571 &f.path().to_string_lossy(),
1572 CacheEffect::Invalidate,
1573 );
1574 assert!(
1575 cache.get(&f.path().to_string_lossy()).is_none(),
1576 "Invalidate must drop the entry"
1577 );
1578 apply_cache_effect(
1579 &mut cache,
1580 &f.path().to_string_lossy(),
1581 CacheEffect::StoreFull("fresh\n".to_string()),
1582 );
1583 assert!(
1584 cache.get(&f.path().to_string_lossy()).is_some(),
1585 "StoreFull must re-populate the entry"
1586 );
1587 }
1588
1589 #[test]
1590 fn identical_old_new_rejected() {
1591 let f = make_temp("fn main() {}\n");
1592 let mut cache = SessionCache::new();
1593 let result = handle(
1594 &mut cache,
1595 &mk_params(f.path(), "fn main() {}", "fn main() {}", false, false),
1596 );
1597 assert!(result.contains("identical"));
1598 }
1599
1600 #[test]
1601 fn edit_already_applied_detected() {
1602 let f = make_temp("fn updated() {}\n");
1603 let (text, effect) = run_io(
1604 &mk_params(
1605 f.path(),
1606 "fn original() {}",
1607 "fn updated() {}",
1608 false,
1609 false,
1610 ),
1611 "",
1612 );
1613 assert!(text.contains("already exists"));
1614 assert!(text.contains("already applied"));
1615 assert!(matches!(effect, CacheEffect::None));
1616 }
1617
1618 #[test]
1619 fn closest_line_hint_shown() {
1620 let f = make_temp(" fn hello() {\n println!(\"hi\");\n }\n");
1621 let (text, _) = run_io(
1622 &mk_params(f.path(), "fn hello(){", "fn hello_world(){", false, false),
1623 "",
1624 );
1625 assert!(text.contains("Closest match at line"));
1626 }
1627
1628 #[test]
1629 fn missing_file_suggests_relocated_path() {
1630 let dir = tempfile::tempdir().unwrap();
1631 std::fs::create_dir_all(dir.path().join(".git")).unwrap();
1632 std::fs::create_dir_all(dir.path().join("src/new")).unwrap();
1633 std::fs::write(dir.path().join("src/new/gizmo.rs"), "fn gizmo() {}\n").unwrap();
1634
1635 let (text, effect) = run_io(
1636 &mk_params(
1637 &dir.path().join("src/old/gizmo.rs"),
1638 "fn gizmo() {}",
1639 "fn gizmo2() {}",
1640 false,
1641 false,
1642 ),
1643 "",
1644 );
1645 assert!(text.contains("same-named file was found"), "got: {text}");
1646 assert!(text.contains("gizmo.rs"), "got: {text}");
1647 assert!(matches!(effect, CacheEffect::None));
1648 }
1649
1650 #[test]
1651 fn old_string_in_other_file_is_reported() {
1652 let dir = tempfile::tempdir().unwrap();
1653 std::fs::create_dir_all(dir.path().join(".git")).unwrap();
1654 let target = dir.path().join("a.rs");
1655 std::fs::write(&target, "fn unrelated_a() {}\n").unwrap();
1656 std::fs::write(dir.path().join("b.rs"), "fn the_target_symbol() {}\n").unwrap();
1657
1658 let (text, _) = run_io(
1659 &mk_params(
1660 &target,
1661 "fn the_target_symbol() {}",
1662 "fn renamed() {}",
1663 false,
1664 false,
1665 ),
1666 "",
1667 );
1668 assert!(text.contains("matching line exists in"), "got: {text}");
1669 assert!(text.contains("b.rs"), "got: {text}");
1670 }
1671
1672 #[cfg(unix)]
1675 #[test]
1676 fn editing_through_a_symlink_is_rejected() {
1677 let dir = tempfile::tempdir().unwrap();
1678 let real = dir.path().join("real.rs");
1679 std::fs::write(&real, "fn old() {}\n").unwrap();
1680 let link = dir.path().join("link.rs");
1681 std::os::unix::fs::symlink(&real, &link).unwrap();
1682
1683 let (text, effect) = run_io(
1684 &mk_params(&link, "fn old() {}", "fn new() {}", false, false),
1685 "",
1686 );
1687 assert!(text.contains("symlink"), "got: {text}");
1688 assert!(matches!(effect, CacheEffect::None));
1689 assert_eq!(std::fs::read_to_string(&real).unwrap(), "fn old() {}\n");
1691 }
1692
1693 #[cfg(unix)]
1696 #[test]
1697 fn creating_over_a_symlink_is_rejected() {
1698 let dir = tempfile::tempdir().unwrap();
1699 let real = dir.path().join("victim.txt");
1700 std::fs::write(&real, "precious").unwrap();
1701 let link = dir.path().join("innocent.txt");
1702 std::os::unix::fs::symlink(&real, &link).unwrap();
1703
1704 let (text, _) = run_io(&mk_params(&link, "", "overwritten", false, true), "");
1705 assert!(
1706 text.contains("symlink") || text.contains("ERROR"),
1707 "got: {text}"
1708 );
1709 assert_eq!(
1710 std::fs::read_to_string(&real).unwrap(),
1711 "precious",
1712 "symlink target must not be modified"
1713 );
1714 }
1715
1716 #[test]
1717 fn regular_file_edit_still_works_after_symlink_guard() {
1718 let dir = tempfile::tempdir().unwrap();
1719 let file = dir.path().join("normal.rs");
1720 std::fs::write(&file, "fn old() {}\n").unwrap();
1721
1722 let (text, _) = run_io(
1723 &mk_params(&file, "fn old() {}", "fn new() {}", false, false),
1724 "",
1725 );
1726 assert!(
1727 text.contains("Edit applied") || !text.starts_with("ERROR"),
1728 "got: {text}"
1729 );
1730 assert_eq!(std::fs::read_to_string(&file).unwrap(), "fn new() {}\n");
1731 }
1732}