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 match try_atomic_write(path, bytes, permissions) {
243 Ok(()) => Ok(()),
244 Err(e) if is_readonly_dir_error(&e) && path.is_file() => {
254 in_place_overwrite(path, bytes, permissions).map_err(|fallback_err| {
255 format!(
256 "ERROR: atomic write failed ({e}); in-place fallback also failed: {fallback_err} ({})",
257 path.display()
258 )
259 })
260 }
261 Err(e) => Err(format!("ERROR: atomic write failed: {e} ({})", path.display())),
262 }
263}
264
265fn try_atomic_write(
269 path: &Path,
270 bytes: &[u8],
271 permissions: Option<&std::fs::Permissions>,
272) -> std::io::Result<()> {
273 use std::io::Write;
274
275 let parent = path.parent().ok_or_else(|| {
276 std::io::Error::new(
277 std::io::ErrorKind::InvalidInput,
278 "invalid path (no parent directory)",
279 )
280 })?;
281 let filename = path
282 .file_name()
283 .ok_or_else(|| {
284 std::io::Error::new(
285 std::io::ErrorKind::InvalidInput,
286 "invalid path (no filename)",
287 )
288 })?
289 .to_string_lossy();
290
291 let pid = std::process::id();
292 let nanos = SystemTime::now()
293 .duration_since(UNIX_EPOCH)
294 .map_or(0, |d| d.as_nanos());
295 let tmp = parent.join(format!(".{filename}.lean-ctx.tmp.{pid}.{nanos}"));
296
297 {
298 let mut f = std::fs::OpenOptions::new()
299 .write(true)
300 .create_new(true)
301 .open(&tmp)?;
302 f.write_all(bytes)?;
303 let _ = f.flush();
304 let _ = f.sync_all();
305 }
306
307 if let Some(perms) = permissions {
308 let _ = std::fs::set_permissions(&tmp, perms.clone());
309 }
310
311 #[cfg(windows)]
312 {
313 if path.exists() {
314 let _ = std::fs::remove_file(path);
315 }
316 }
317
318 if let Err(e) = std::fs::rename(&tmp, path) {
319 let _ = std::fs::remove_file(&tmp);
322 return Err(e);
323 }
324 Ok(())
325}
326
327fn in_place_overwrite(
331 path: &Path,
332 bytes: &[u8],
333 permissions: Option<&std::fs::Permissions>,
334) -> std::io::Result<()> {
335 use std::io::Write;
336
337 let mut opts = std::fs::OpenOptions::new();
338 opts.write(true).truncate(true);
339 #[cfg(unix)]
340 {
341 use std::os::unix::fs::OpenOptionsExt;
342 opts.custom_flags(libc::O_NOFOLLOW);
345 }
346
347 let mut f = opts.open(path)?;
348 f.write_all(bytes)?;
349 let _ = f.flush();
350 let _ = f.sync_all();
351
352 if let Some(perms) = permissions {
353 let _ = std::fs::set_permissions(path, perms.clone());
354 }
355 Ok(())
356}
357
358fn is_readonly_dir_error(e: &std::io::Error) -> bool {
362 if e.kind() == std::io::ErrorKind::PermissionDenied {
363 return true;
364 }
365 #[cfg(unix)]
366 {
367 matches!(
368 e.raw_os_error(),
369 Some(libc::EROFS | libc::EACCES | libc::EPERM)
370 )
371 }
372 #[cfg(not(unix))]
373 {
374 false
375 }
376}
377
378fn build_diff_evidence(old: &str, new: &str, label: &str, max_lines: usize) -> String {
379 let diff = similar::TextDiff::from_lines(old, new)
380 .unified_diff()
381 .context_radius(3)
382 .header(label, label)
383 .to_string();
384 let diff = crate::core::redaction::redact_text(&diff);
389
390 let mut out = String::new();
391 for (i, line) in diff.lines().enumerate() {
392 if i >= max_lines {
393 out.push_str(&format!("\n... diff truncated (max_lines={max_lines})"));
394 break;
395 }
396 out.push_str(line);
397 out.push('\n');
398 }
399 out.trim_end_matches('\n').to_string()
400}
401
402pub enum CacheEffect {
409 None,
411 Invalidate,
413 StoreFull(String),
416}
417
418pub fn handle(cache: &mut SessionCache, params: &EditParams) -> String {
423 let last_mode = cache
424 .get(¶ms.path)
425 .map(|e| e.last_mode.clone())
426 .unwrap_or_default();
427 let (text, effect) = run_io(params, &last_mode);
428 record_outcome(params, &last_mode, &text, &effect);
429 apply_cache_effect(cache, ¶ms.path, effect);
430 text
431}
432
433pub fn record_outcome(params: &EditParams, last_mode: &str, text: &str, effect: &CacheEffect) {
440 if params.create {
441 return;
442 }
443 let success = matches!(effect, CacheEffect::Invalidate);
444 let not_found_failure = matches!(effect, CacheEffect::StoreFull(_))
445 || (matches!(effect, CacheEffect::None)
446 && text.starts_with("ERROR: old_string not found")
447 && !text.contains("already"));
448 if success || not_found_failure {
449 crate::core::edit_quality::record_edit_outcome(¶ms.path, last_mode, success);
450 }
451}
452
453pub fn apply_cache_effect(cache: &mut SessionCache, path: &str, effect: CacheEffect) {
455 match effect {
456 CacheEffect::None => {}
457 CacheEffect::Invalidate => {
458 cache.invalidate(path);
459 }
460 CacheEffect::StoreFull(content) => {
461 cache.store(path, &content);
462 cache.mark_full_delivered(path);
463 }
464 }
465}
466
467pub fn run_io(params: &EditParams, last_mode: &str) -> (String, CacheEffect) {
473 let file_path = ¶ms.path;
474
475 if params.create {
476 return handle_create(file_path, ¶ms.new_string, params);
477 }
478
479 let cap = crate::core::limits::max_read_bytes();
480 let path = Path::new(file_path);
481 let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
482 Ok(p) => p,
483 Err(e) => {
484 if !path.exists() {
487 let hint = crate::tools::edit_recovery::moved_or_deleted_hint(path);
488 return (format!("{e}{hint}"), CacheEffect::None);
489 }
490 return (e, CacheEffect::None);
491 }
492 };
493 if let Err(e) = verify_expected_preimage(&pre, params) {
494 return (e, CacheEffect::None);
495 }
496 let content = &pre.text;
497
498 if params.old_string.is_empty() {
499 return (
500 "ERROR: old_string must not be empty (use create=true to create a new file)".into(),
501 CacheEffect::None,
502 );
503 }
504
505 if params.old_string == params.new_string {
506 return (
507 "ERROR: old_string and new_string are identical — nothing to change.".into(),
508 CacheEffect::None,
509 );
510 }
511
512 let uses_crlf = pre.uses_crlf;
513 let old_str = ¶ms.old_string;
514 let new_str = ¶ms.new_string;
515
516 let occurrences = content.matches(old_str).count();
517
518 if occurrences > 0 {
519 let args = ReplaceArgs {
520 content,
521 old_str,
522 new_str,
523 occurrences,
524 replace_all: params.replace_all,
525 old_tokens: count_tokens(¶ms.old_string),
526 new_tokens: count_tokens(¶ms.new_string),
527 };
528 return do_replace(path, &pre, params, cap, &args);
529 }
530
531 if uses_crlf && !old_str.contains('\r') {
533 let old_crlf = old_str.replace('\n', "\r\n");
534 let occ = content.matches(&old_crlf).count();
535 if occ > 0 {
536 let new_crlf = new_str.replace('\n', "\r\n");
537 let args = ReplaceArgs {
538 content,
539 old_str: &old_crlf,
540 new_str: &new_crlf,
541 occurrences: occ,
542 replace_all: params.replace_all,
543 old_tokens: count_tokens(¶ms.old_string),
544 new_tokens: count_tokens(¶ms.new_string),
545 };
546 return do_replace(path, &pre, params, cap, &args);
547 }
548 } else if !uses_crlf && old_str.contains("\r\n") {
549 let old_lf = old_str.replace("\r\n", "\n");
550 let occ = content.matches(&old_lf).count();
551 if occ > 0 {
552 let new_lf = new_str.replace("\r\n", "\n");
553 let args = ReplaceArgs {
554 content,
555 old_str: &old_lf,
556 new_str: &new_lf,
557 occurrences: occ,
558 replace_all: params.replace_all,
559 old_tokens: count_tokens(¶ms.old_string),
560 new_tokens: count_tokens(¶ms.new_string),
561 };
562 return do_replace(path, &pre, params, cap, &args);
563 }
564 }
565
566 let normalized_content = trim_trailing_per_line(content);
568 let normalized_old = trim_trailing_per_line(old_str);
569 if !normalized_old.is_empty() && normalized_content.contains(&normalized_old) {
570 let line_sep = if uses_crlf { "\r\n" } else { "\n" };
571 let adapted_new = adapt_new_string_to_line_sep(new_str, line_sep);
572 let adapted_old = find_original_span(content, &normalized_old);
573 if let Some(original_match) = adapted_old {
574 let occ = content.matches(&original_match).count();
575 let args = ReplaceArgs {
576 content,
577 old_str: &original_match,
578 new_str: &adapted_new,
579 occurrences: occ,
580 replace_all: params.replace_all,
581 old_tokens: count_tokens(¶ms.old_string),
582 new_tokens: count_tokens(¶ms.new_string),
583 };
584 return do_replace(path, &pre, params, cap, &args);
585 }
586 }
587
588 if content.contains(new_str) {
590 return (
591 format!(
592 "ERROR: old_string not found in {file_path}, but new_string already exists in the file. \
593 The edit was likely already applied (by a previous tool call or another agent)."
594 ),
595 CacheEffect::None,
596 );
597 }
598
599 let preview = if old_str.len() > 80 {
600 format!("{}...", &old_str[..old_str.floor_char_boundary(77)])
601 } else {
602 old_str.clone()
603 };
604 let hint = if uses_crlf {
605 " (file uses CRLF line endings)"
606 } else {
607 ""
608 };
609
610 let closest_hint = find_closest_line_hint(content, old_str);
612 let cross_file = crate::tools::edit_recovery::cross_file_hint(path, old_str);
614
615 let (escalation, effect) = auto_escalate_reread(last_mode, file_path);
616
617 (
618 format!(
619 "ERROR: old_string not found in {file_path}{hint}. \
620 Make sure it matches exactly (including whitespace/indentation).\n\
621 Searched for: {preview}{closest_hint}{cross_file}{escalation}"
622 ),
623 effect,
624 )
625}
626
627fn find_closest_line_hint(content: &str, old_str: &str) -> String {
630 let first_line = old_str.lines().next().unwrap_or("").trim();
631 if first_line.len() < 4 {
632 return String::new();
633 }
634
635 let mut best_line: Option<(usize, &str)> = None;
636
637 for (i, line) in content.lines().enumerate() {
639 if line.contains(first_line) {
640 best_line = Some((i + 1, line));
641 break;
642 }
643 }
644
645 if best_line.is_none() {
647 let keywords: Vec<&str> = first_line
648 .split(|c: char| !c.is_alphanumeric() && c != '_')
649 .filter(|w| w.len() >= 4)
650 .collect();
651
652 if let Some(keyword) = keywords.first() {
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 let mut preimage: Option<FilePreimage> = None;
837 if path.exists() {
838 let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
839 Ok(p) => p,
840 Err(e) => return (e, CacheEffect::None),
841 };
842 if let Err(e) = verify_expected_preimage(&pre, params) {
843 return (e, CacheEffect::None);
844 }
845 if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
846 return (e, CacheEffect::None);
847 }
848 preimage = Some(pre);
849 }
850
851 if let Some(parent) = path.parent()
852 && !parent.exists()
853 && let Err(e) = std::fs::create_dir_all(parent)
854 {
855 return (
856 format!("ERROR: cannot create directory {}: {e}", parent.display()),
857 CacheEffect::None,
858 );
859 }
860
861 let backup_path = if params.backup {
862 if let Some(pre) = &preimage {
863 let bp = params
864 .backup_path
865 .as_deref()
866 .map(PathBuf::from)
867 .or_else(|| default_backup_path(path));
868 let Some(bp) = bp else {
869 return (
870 format!("ERROR: cannot compute backup path for {}", path.display()),
871 CacheEffect::None,
872 );
873 };
874 if let Err(e) =
875 write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
876 {
877 return (
878 format!("ERROR: cannot create backup {}: {e}", bp.display()),
879 CacheEffect::None,
880 );
881 }
882 Some(bp.to_string_lossy().to_string())
883 } else {
884 None
885 }
886 } else {
887 None
888 };
889
890 let perms = preimage.as_ref().map(|p| &p.permissions);
891 if let Err(e) = write_atomic_bytes_with_permissions(path, content.as_bytes(), perms) {
892 return (e, CacheEffect::None);
893 }
894
895 let lines = content.lines().count();
896 let tokens = count_tokens(content);
897 let short = path.file_name().map_or_else(
898 || path.to_string_lossy().to_string(),
899 |f| f.to_string_lossy().to_string(),
900 );
901
902 let mut out = format!("✓ created {short}: {lines} lines, {tokens} tok");
903 if let Some(bp) = backup_path {
904 out.push_str(&format!("\nbackup: {bp}"));
905 }
906 (out, CacheEffect::Invalidate)
907}
908
909fn trim_trailing_per_line(s: &str) -> String {
910 s.lines().map(str::trim_end).collect::<Vec<_>>().join("\n")
911}
912
913fn adapt_new_string_to_line_sep(s: &str, sep: &str) -> String {
914 let normalized = s.replace("\r\n", "\n");
915 if sep == "\r\n" {
916 normalized.replace('\n', "\r\n")
917 } else {
918 normalized
919 }
920}
921
922fn find_original_span(content: &str, normalized_needle: &str) -> Option<String> {
925 let needle_lines: Vec<&str> = normalized_needle.lines().collect();
926 if needle_lines.is_empty() {
927 return None;
928 }
929
930 let content_lines: Vec<&str> = content.lines().collect();
931
932 'outer: for start in 0..content_lines.len() {
933 if start + needle_lines.len() > content_lines.len() {
934 break;
935 }
936 for (i, nl) in needle_lines.iter().enumerate() {
937 if content_lines[start + i].trim_end() != *nl {
938 continue 'outer;
939 }
940 }
941 let sep = if content.contains("\r\n") {
942 "\r\n"
943 } else {
944 "\n"
945 };
946 return Some(content_lines[start..start + needle_lines.len()].join(sep));
947 }
948 None
949}
950
951#[cfg(test)]
952mod tests {
953 use super::*;
954 use std::io::Write;
955 use tempfile::NamedTempFile;
956
957 fn make_temp(content: &str) -> NamedTempFile {
958 let mut f = NamedTempFile::new().unwrap();
959 f.write_all(content.as_bytes()).unwrap();
960 f
961 }
962
963 fn mk_params(path: &Path, old: &str, new: &str, replace_all: bool, create: bool) -> EditParams {
964 EditParams {
965 path: path.to_string_lossy().to_string(),
966 old_string: old.to_string(),
967 new_string: new.to_string(),
968 replace_all,
969 create,
970 expected_md5: None,
971 expected_size: None,
972 expected_mtime_ms: None,
973 backup: false,
974 backup_path: None,
975 evidence: false,
976 diff_max_lines: 200,
977 allow_lossy_utf8: false,
978 }
979 }
980
981 #[test]
982 fn replace_single_occurrence() {
983 let f = make_temp("fn hello() {\n println!(\"hello\");\n}\n");
984 let mut cache = SessionCache::new();
985 let result = handle(
986 &mut cache,
987 &mk_params(f.path(), "hello", "world", false, false),
988 );
989 assert!(result.contains("ERROR"), "should fail: 'hello' appears 2x");
990 }
991
992 #[test]
993 fn replace_all() {
994 let f = make_temp("aaa bbb aaa\n");
995 let mut cache = SessionCache::new();
996 let result = handle(&mut cache, &mk_params(f.path(), "aaa", "ccc", true, false));
997 assert!(result.contains("2 replacements"));
998 let content = std::fs::read_to_string(f.path()).unwrap();
999 assert_eq!(content, "ccc bbb ccc\n");
1000 }
1001
1002 #[test]
1003 fn not_found_error() {
1004 let f = make_temp("some content\n");
1005 let mut cache = SessionCache::new();
1006 let result = handle(
1007 &mut cache,
1008 &mk_params(f.path(), "nonexistent", "x", false, false),
1009 );
1010 assert!(result.contains("ERROR: old_string not found"));
1011 }
1012
1013 #[test]
1014 fn create_new_file() {
1015 let dir = tempfile::tempdir().unwrap();
1016 let path = dir.path().join("sub/new_file.txt");
1017 let mut cache = SessionCache::new();
1018 let result = handle(
1019 &mut cache,
1020 &mk_params(&path, "", "line1\nline2\nline3\n", false, true),
1021 );
1022 assert!(result.contains("created new_file.txt"));
1023 assert!(result.contains("3 lines"));
1024 assert!(path.exists());
1025 }
1026
1027 #[test]
1028 fn readonly_dir_error_classification() {
1029 assert!(is_readonly_dir_error(&std::io::Error::from(
1030 std::io::ErrorKind::PermissionDenied
1031 )));
1032 assert!(!is_readonly_dir_error(&std::io::Error::from(
1033 std::io::ErrorKind::NotFound
1034 )));
1035 #[cfg(unix)]
1036 {
1037 assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
1038 libc::EROFS
1039 )));
1040 assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
1041 libc::EACCES
1042 )));
1043 assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
1044 libc::EPERM
1045 )));
1046 }
1047 }
1048
1049 #[cfg(unix)]
1050 #[test]
1051 fn in_place_overwrite_truncates_existing_file() {
1052 let dir = tempfile::tempdir().unwrap();
1053 let path = dir.path().join("config.jsonc");
1054 std::fs::write(&path, b"longer original content").unwrap();
1055 in_place_overwrite(&path, b"short", None).unwrap();
1056 assert_eq!(std::fs::read(&path).unwrap(), b"short");
1057 }
1058
1059 #[cfg(unix)]
1066 #[test]
1067 fn write_falls_back_on_readonly_parent_dir() {
1068 use std::os::unix::fs::PermissionsExt;
1069
1070 if unsafe { libc::geteuid() } == 0 {
1072 return;
1073 }
1074
1075 let dir = tempfile::tempdir().unwrap();
1076 let path = dir.path().join("opencode.jsonc");
1077 std::fs::write(&path, b"hello").unwrap();
1078
1079 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap();
1082
1083 let res = write_atomic_bytes_with_permissions(&path, b"world", None);
1084
1085 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
1087
1088 assert!(res.is_ok(), "in-place fallback should succeed: {res:?}");
1089 assert_eq!(std::fs::read(&path).unwrap(), b"world");
1090 }
1091
1092 #[cfg(unix)]
1095 #[test]
1096 fn handle_edit_succeeds_on_readonly_parent_dir() {
1097 use std::os::unix::fs::PermissionsExt;
1098
1099 if unsafe { libc::geteuid() } == 0 {
1101 return;
1102 }
1103
1104 let dir = tempfile::tempdir().unwrap();
1105 let path = dir.path().join("opencode.jsonc");
1106 std::fs::write(&path, "hello world\n").unwrap();
1107 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap();
1108
1109 let mut cache = SessionCache::new();
1110 let result = handle(
1111 &mut cache,
1112 &mk_params(&path, "hello", "goodbye", false, false),
1113 );
1114
1115 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
1116
1117 assert!(
1118 result.contains('✓'),
1119 "edit should succeed via in-place fallback: {result}"
1120 );
1121 assert_eq!(std::fs::read_to_string(&path).unwrap(), "goodbye world\n");
1122 }
1123
1124 #[test]
1125 fn unique_match_succeeds() {
1126 let f = make_temp("fn main() {\n let x = 42;\n}\n");
1127 let mut cache = SessionCache::new();
1128 let result = handle(
1129 &mut cache,
1130 &mk_params(f.path(), "let x = 42", "let x = 99", false, false),
1131 );
1132 assert!(result.contains("✓"));
1133 assert!(result.contains("1 replacement"));
1134 let content = std::fs::read_to_string(f.path()).unwrap();
1135 assert!(content.contains("let x = 99"));
1136 }
1137
1138 #[test]
1139 fn crlf_file_with_lf_search() {
1140 let f = make_temp("line1\r\nline2\r\nline3\r\n");
1141 let mut cache = SessionCache::new();
1142 let result = handle(
1143 &mut cache,
1144 &mk_params(f.path(), "line1\nline2", "changed1\nchanged2", false, false),
1145 );
1146 assert!(result.contains("✓"), "CRLF fallback should work: {result}");
1147 let content = std::fs::read_to_string(f.path()).unwrap();
1148 assert!(
1149 content.contains("changed1\r\nchanged2"),
1150 "new_string should be adapted to CRLF: {content:?}"
1151 );
1152 assert!(
1153 content.contains("\r\nline3\r\n"),
1154 "rest of file should keep CRLF: {content:?}"
1155 );
1156 }
1157
1158 #[test]
1159 fn lf_file_with_crlf_search() {
1160 let f = make_temp("line1\nline2\nline3\n");
1161 let mut cache = SessionCache::new();
1162 let result = handle(
1163 &mut cache,
1164 &mk_params(f.path(), "line1\r\nline2", "a\r\nb", false, false),
1165 );
1166 assert!(result.contains("✓"), "LF fallback should work: {result}");
1167 let content = std::fs::read_to_string(f.path()).unwrap();
1168 assert!(
1169 content.contains("a\nb"),
1170 "new_string should be adapted to LF: {content:?}"
1171 );
1172 }
1173
1174 #[test]
1175 fn trailing_whitespace_tolerance() {
1176 let f = make_temp(" let x = 1; \n let y = 2;\n");
1177 let mut cache = SessionCache::new();
1178 let result = handle(
1179 &mut cache,
1180 &mk_params(
1181 f.path(),
1182 " let x = 1;\n let y = 2;",
1183 " let x = 10;\n let y = 20;",
1184 false,
1185 false,
1186 ),
1187 );
1188 assert!(
1189 result.contains("✓"),
1190 "trailing whitespace tolerance should work: {result}"
1191 );
1192 let content = std::fs::read_to_string(f.path()).unwrap();
1193 assert!(content.contains("let x = 10;"));
1194 assert!(content.contains("let y = 20;"));
1195 }
1196
1197 #[test]
1198 fn crlf_with_trailing_whitespace() {
1199 let f = make_temp(" const a = 1; \r\n const b = 2;\r\n");
1200 let mut cache = SessionCache::new();
1201 let result = handle(
1202 &mut cache,
1203 &mk_params(
1204 f.path(),
1205 " const a = 1;\n const b = 2;",
1206 " const a = 10;\n const b = 20;",
1207 false,
1208 false,
1209 ),
1210 );
1211 assert!(
1212 result.contains("✓"),
1213 "CRLF + trailing whitespace should work: {result}"
1214 );
1215 let content = std::fs::read_to_string(f.path()).unwrap();
1216 assert!(content.contains("const a = 10;"));
1217 assert!(content.contains("const b = 20;"));
1218 }
1219
1220 #[test]
1221 fn rejects_invalid_utf8_by_default() {
1222 let mut f = NamedTempFile::new().unwrap();
1223 f.write_all(&[0xff, 0xfe, 0xfd]).unwrap();
1224 let mut cache = SessionCache::new();
1225 let result = handle(&mut cache, &mk_params(f.path(), "a", "b", false, false));
1226 assert!(
1227 result.contains("not valid UTF-8"),
1228 "expected utf8 rejection, got: {result}"
1229 );
1230 }
1231
1232 #[test]
1233 fn allows_lossy_utf8_only_when_enabled() {
1234 let mut f = NamedTempFile::new().unwrap();
1235 f.write_all(&[0xff, 0xfe, 0xfd]).unwrap();
1236 let mut cache = SessionCache::new();
1237 let mut p = mk_params(f.path(), "a", "b", false, false);
1238 p.allow_lossy_utf8 = true;
1239 let result = handle(&mut cache, &p);
1240 assert!(
1241 !result.contains("not valid UTF-8"),
1242 "lossy mode should avoid utf8 hard error, got: {result}"
1243 );
1244 }
1245
1246 #[test]
1247 fn expected_md5_mismatch_fails_without_writing() {
1248 let f = make_temp("aaa\n");
1249 let mut cache = SessionCache::new();
1250 let mut p = mk_params(f.path(), "aaa", "bbb", false, false);
1251 p.expected_md5 = Some("deadbeef".to_string());
1252 let result = handle(&mut cache, &p);
1253 assert!(
1254 result.contains("preimage mismatch"),
1255 "expected preimage mismatch, got: {result}"
1256 );
1257 let content = std::fs::read_to_string(f.path()).unwrap();
1258 assert_eq!(content, "aaa\n");
1259 }
1260
1261 #[test]
1262 fn backup_is_created_when_enabled() {
1263 let f = make_temp("aaa\n");
1264 let mut cache = SessionCache::new();
1265 let mut p = mk_params(f.path(), "aaa", "bbb", false, false);
1266 p.backup = true;
1267 let out = handle(&mut cache, &p);
1268 assert!(out.contains("backup:"), "expected backup path, got: {out}");
1269 let bp = out
1270 .lines()
1271 .find_map(|l| l.strip_prefix("backup: "))
1272 .expect("backup line");
1273 let backup_content = std::fs::read_to_string(bp).unwrap();
1274 assert_eq!(backup_content, "aaa\n");
1275 let content = std::fs::read_to_string(f.path()).unwrap();
1276 assert_eq!(content, "bbb\n");
1277 }
1278
1279 #[test]
1280 fn evidence_diff_is_emitted_when_enabled() {
1281 let f = make_temp("line1\nline2\n");
1282 let mut cache = SessionCache::new();
1283 let mut p = mk_params(f.path(), "line2", "changed2", false, false);
1284 p.evidence = true;
1285 p.diff_max_lines = 50;
1286 let out = handle(&mut cache, &p);
1287 assert!(out.contains("```diff"), "expected diff fence, got: {out}");
1288 assert!(
1289 out.contains("preimage:"),
1290 "expected preimage metadata, got: {out}"
1291 );
1292 assert!(
1293 out.contains("postimage:"),
1294 "expected postimage metadata, got: {out}"
1295 );
1296 }
1297
1298 #[test]
1299 fn detects_toctou_via_preimage_guard() {
1300 let f = make_temp("aaa\n");
1301 let cap = crate::core::limits::max_read_bytes();
1302 let pre = read_preimage(f.path(), cap, false).unwrap();
1303 std::fs::write(f.path(), "bbb\n").unwrap();
1304 let err = ensure_preimage_still_matches(f.path(), &pre.fp, cap).unwrap_err();
1305 assert!(err.contains("TOCTOU guard"), "unexpected error: {err}");
1306 }
1307
1308 #[test]
1312 fn run_io_success_reports_invalidate_effect() {
1313 let f = make_temp("fn main() {\n let x = 42;\n}\n");
1314 let (text, effect) = run_io(
1315 &mk_params(f.path(), "let x = 42", "let x = 99", false, false),
1316 "",
1317 );
1318 assert!(text.contains("✓"), "expected success: {text}");
1319 assert!(
1320 matches!(effect, CacheEffect::Invalidate),
1321 "successful edit must invalidate the cache entry"
1322 );
1323 let content = std::fs::read_to_string(f.path()).unwrap();
1324 assert!(content.contains("let x = 99"));
1325 }
1326
1327 #[test]
1328 fn run_io_failure_reports_no_cache_effect() {
1329 let f = make_temp("some content\n");
1330 let (text, effect) = run_io(&mk_params(f.path(), "nonexistent", "x", false, false), "");
1331 assert!(text.contains("ERROR: old_string not found"));
1332 assert!(
1333 matches!(effect, CacheEffect::None),
1334 "a failed edit must not mutate the cache"
1335 );
1336 }
1337
1338 #[test]
1342 fn run_io_concurrent_edits_to_different_files_all_succeed() {
1343 use std::sync::Arc;
1344 let dir = Arc::new(tempfile::tempdir().unwrap());
1345 let n = 16;
1346 let mut paths = Vec::new();
1347 for i in 0..n {
1348 let p = dir.path().join(format!("file_{i}.txt"));
1349 std::fs::write(&p, format!("value = {i}\n")).unwrap();
1350 paths.push(p);
1351 }
1352 let barrier = Arc::new(std::sync::Barrier::new(n));
1353 let mut handles = Vec::new();
1354 for (i, p) in paths.into_iter().enumerate() {
1355 let barrier = Arc::clone(&barrier);
1356 handles.push(std::thread::spawn(move || {
1357 barrier.wait();
1358 let (text, effect) = run_io(
1359 &mk_params(
1360 &p,
1361 &format!("value = {i}"),
1362 &format!("value = {}", i + 1000),
1363 false,
1364 false,
1365 ),
1366 "",
1367 );
1368 assert!(text.contains("✓"), "edit {i} failed: {text}");
1369 assert!(matches!(effect, CacheEffect::Invalidate));
1370 (p, i)
1371 }));
1372 }
1373 for h in handles {
1374 let (p, i) = h.join().unwrap();
1375 let content = std::fs::read_to_string(&p).unwrap();
1376 assert_eq!(content, format!("value = {}\n", i + 1000));
1377 }
1378 }
1379
1380 #[test]
1381 fn run_io_escalation_reports_store_full_effect() {
1382 let f = make_temp("line a\nline b\nline c\n");
1386 let (text, effect) = run_io(
1387 &mk_params(f.path(), "definitely-not-present", "x", false, false),
1388 "signatures",
1389 );
1390 assert!(
1391 text.contains("[auto-escalation]"),
1392 "expected escalation: {text}"
1393 );
1394 match effect {
1395 CacheEffect::StoreFull(content) => {
1396 assert!(content.contains("line a") && content.contains("line c"));
1397 }
1398 _ => panic!("escalation must report a StoreFull cache effect"),
1399 }
1400 }
1401
1402 #[test]
1403 fn apply_cache_effect_invalidate_and_store() {
1404 let f = make_temp("hello\n");
1405 let mut cache = SessionCache::new();
1406 cache.store(&f.path().to_string_lossy(), "hello\n");
1407 apply_cache_effect(
1408 &mut cache,
1409 &f.path().to_string_lossy(),
1410 CacheEffect::Invalidate,
1411 );
1412 assert!(
1413 cache.get(&f.path().to_string_lossy()).is_none(),
1414 "Invalidate must drop the entry"
1415 );
1416 apply_cache_effect(
1417 &mut cache,
1418 &f.path().to_string_lossy(),
1419 CacheEffect::StoreFull("fresh\n".to_string()),
1420 );
1421 assert!(
1422 cache.get(&f.path().to_string_lossy()).is_some(),
1423 "StoreFull must re-populate the entry"
1424 );
1425 }
1426
1427 #[test]
1428 fn identical_old_new_rejected() {
1429 let f = make_temp("fn main() {}\n");
1430 let mut cache = SessionCache::new();
1431 let result = handle(
1432 &mut cache,
1433 &mk_params(f.path(), "fn main() {}", "fn main() {}", false, false),
1434 );
1435 assert!(result.contains("identical"));
1436 }
1437
1438 #[test]
1439 fn edit_already_applied_detected() {
1440 let f = make_temp("fn updated() {}\n");
1441 let (text, effect) = run_io(
1442 &mk_params(
1443 f.path(),
1444 "fn original() {}",
1445 "fn updated() {}",
1446 false,
1447 false,
1448 ),
1449 "",
1450 );
1451 assert!(text.contains("already exists"));
1452 assert!(text.contains("already applied"));
1453 assert!(matches!(effect, CacheEffect::None));
1454 }
1455
1456 #[test]
1457 fn closest_line_hint_shown() {
1458 let f = make_temp(" fn hello() {\n println!(\"hi\");\n }\n");
1459 let (text, _) = run_io(
1460 &mk_params(f.path(), "fn hello(){", "fn hello_world(){", false, false),
1461 "",
1462 );
1463 assert!(text.contains("Closest match at line"));
1464 }
1465
1466 #[test]
1467 fn missing_file_suggests_relocated_path() {
1468 let dir = tempfile::tempdir().unwrap();
1469 std::fs::create_dir_all(dir.path().join(".git")).unwrap();
1470 std::fs::create_dir_all(dir.path().join("src/new")).unwrap();
1471 std::fs::write(dir.path().join("src/new/gizmo.rs"), "fn gizmo() {}\n").unwrap();
1472
1473 let (text, effect) = run_io(
1474 &mk_params(
1475 &dir.path().join("src/old/gizmo.rs"),
1476 "fn gizmo() {}",
1477 "fn gizmo2() {}",
1478 false,
1479 false,
1480 ),
1481 "",
1482 );
1483 assert!(text.contains("same-named file was found"), "got: {text}");
1484 assert!(text.contains("gizmo.rs"), "got: {text}");
1485 assert!(matches!(effect, CacheEffect::None));
1486 }
1487
1488 #[test]
1489 fn old_string_in_other_file_is_reported() {
1490 let dir = tempfile::tempdir().unwrap();
1491 std::fs::create_dir_all(dir.path().join(".git")).unwrap();
1492 let target = dir.path().join("a.rs");
1493 std::fs::write(&target, "fn unrelated_a() {}\n").unwrap();
1494 std::fs::write(dir.path().join("b.rs"), "fn the_target_symbol() {}\n").unwrap();
1495
1496 let (text, _) = run_io(
1497 &mk_params(
1498 &target,
1499 "fn the_target_symbol() {}",
1500 "fn renamed() {}",
1501 false,
1502 false,
1503 ),
1504 "",
1505 );
1506 assert!(text.contains("matching line exists in"), "got: {text}");
1507 assert!(text.contains("b.rs"), "got: {text}");
1508 }
1509
1510 #[cfg(unix)]
1513 #[test]
1514 fn editing_through_a_symlink_is_rejected() {
1515 let dir = tempfile::tempdir().unwrap();
1516 let real = dir.path().join("real.rs");
1517 std::fs::write(&real, "fn old() {}\n").unwrap();
1518 let link = dir.path().join("link.rs");
1519 std::os::unix::fs::symlink(&real, &link).unwrap();
1520
1521 let (text, effect) = run_io(
1522 &mk_params(&link, "fn old() {}", "fn new() {}", false, false),
1523 "",
1524 );
1525 assert!(text.contains("symlink"), "got: {text}");
1526 assert!(matches!(effect, CacheEffect::None));
1527 assert_eq!(std::fs::read_to_string(&real).unwrap(), "fn old() {}\n");
1529 }
1530
1531 #[cfg(unix)]
1534 #[test]
1535 fn creating_over_a_symlink_is_rejected() {
1536 let dir = tempfile::tempdir().unwrap();
1537 let real = dir.path().join("victim.txt");
1538 std::fs::write(&real, "precious").unwrap();
1539 let link = dir.path().join("innocent.txt");
1540 std::os::unix::fs::symlink(&real, &link).unwrap();
1541
1542 let (text, _) = run_io(&mk_params(&link, "", "overwritten", false, true), "");
1543 assert!(
1544 text.contains("symlink") || text.contains("ERROR"),
1545 "got: {text}"
1546 );
1547 assert_eq!(
1548 std::fs::read_to_string(&real).unwrap(),
1549 "precious",
1550 "symlink target must not be modified"
1551 );
1552 }
1553
1554 #[test]
1555 fn regular_file_edit_still_works_after_symlink_guard() {
1556 let dir = tempfile::tempdir().unwrap();
1557 let file = dir.path().join("normal.rs");
1558 std::fs::write(&file, "fn old() {}\n").unwrap();
1559
1560 let (text, _) = run_io(
1561 &mk_params(&file, "fn old() {}", "fn new() {}", false, false),
1562 "",
1563 );
1564 assert!(
1565 text.contains("Edit applied") || !text.starts_with("ERROR"),
1566 "got: {text}"
1567 );
1568 assert_eq!(std::fs::read_to_string(&file).unwrap(), "fn new() {}\n");
1569 }
1570}