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