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