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) {
457 return (e, CacheEffect::None);
458 }
459
460 let backup_path = if params.backup {
461 let bp = params
462 .backup_path
463 .as_deref()
464 .map(PathBuf::from)
465 .or_else(|| default_backup_path(path));
466 let Some(bp) = bp else {
467 return (
468 format!("ERROR: cannot compute backup path for {}", path.display()),
469 CacheEffect::None,
470 );
471 };
472 if let Err(e) = write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
473 {
474 return (
475 format!("ERROR: cannot create backup {}: {e}", bp.display()),
476 CacheEffect::None,
477 );
478 }
479 Some(bp.to_string_lossy().to_string())
480 } else {
481 None
482 };
483
484 if let Err(e) =
485 write_atomic_bytes_with_permissions(path, new_content.as_bytes(), Some(&pre.permissions))
486 {
487 return (e, CacheEffect::None);
488 }
489
490 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
491 bt.record_edit(¶ms.path);
492 }
493
494 let old_lines = args.content.lines().count();
495 let new_lines = new_content.lines().count();
496 let line_delta = new_lines as i64 - old_lines as i64;
497 let delta_str = if line_delta > 0 {
498 format!("+{line_delta}")
499 } else {
500 format!("{line_delta}")
501 };
502
503 let old_tokens = args.old_tokens;
504 let new_tokens = args.new_tokens;
505
506 let replaced_str = if args.replace_all && args.occurrences > 1 {
507 format!("{} replacements", args.occurrences)
508 } else {
509 "1 replacement".into()
510 };
511
512 let short = path.file_name().map_or_else(
513 || path.to_string_lossy().to_string(),
514 |f| f.to_string_lossy().to_string(),
515 );
516
517 let post_mtime_ms = std::fs::metadata(path)
518 .ok()
519 .and_then(|m| m.modified().ok())
520 .map_or(0, system_time_to_millis);
521 let post_fp = FileFingerprint {
522 size: new_content.len() as u64,
523 mtime_ms: post_mtime_ms,
524 md5: crate::core::hasher::hash_hex(new_content.as_bytes()),
525 };
526
527 let mut out = format!(
528 "✓ {short}: {replaced_str}, {delta_str} lines ({old_tokens}→{new_tokens} tok)\n\
529preimage: bytes={}, mtime_ms={}, md5={}\n\
530postimage: bytes={}, mtime_ms={}, md5={}",
531 pre.fp.size, pre.fp.mtime_ms, pre.fp.md5, post_fp.size, post_fp.mtime_ms, post_fp.md5
532 );
533 if let Some(bp) = backup_path {
534 out.push_str(&format!("\nbackup: {bp}"));
535 }
536 if params.evidence {
537 let diff = build_diff_evidence(args.content, &new_content, &short, params.diff_max_lines);
538 out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
539 out.push_str(&diff);
540 out.push_str("\n```");
541 }
542 if let Some(notice) = health_notice {
543 out.push_str("\n\n");
544 out.push_str(¬ice);
545 }
546 (out, CacheEffect::Invalidate)
547}
548
549fn handle_create(file_path: &str, content: &str, params: &EditParams) -> (String, CacheEffect) {
550 let path = Path::new(file_path);
551 let cap = crate::core::limits::max_read_bytes();
552
553 if let Err(e) = crate::core::pathjail::enforce_writable(path) {
557 return (format!("ERROR: {e}"), CacheEffect::None);
558 }
559
560 let mut preimage: Option<FilePreimage> = None;
561 if path.exists() {
562 let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
563 Ok(p) => p,
564 Err(e) => return (e, CacheEffect::None),
565 };
566 if let Err(e) = verify_expected_preimage(&pre, params) {
567 return (e, CacheEffect::None);
568 }
569 if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
570 return (e, CacheEffect::None);
571 }
572 preimage = Some(pre);
573 }
574
575 if let Some(parent) = path.parent()
576 && !parent.exists()
577 && let Err(e) = std::fs::create_dir_all(parent)
578 {
579 return (
580 format!("ERROR: cannot create directory {}: {e}", parent.display()),
581 CacheEffect::None,
582 );
583 }
584
585 let backup_path = if params.backup {
586 if let Some(pre) = &preimage {
587 let bp = params
588 .backup_path
589 .as_deref()
590 .map(PathBuf::from)
591 .or_else(|| default_backup_path(path));
592 let Some(bp) = bp else {
593 return (
594 format!("ERROR: cannot compute backup path for {}", path.display()),
595 CacheEffect::None,
596 );
597 };
598 if let Err(e) =
599 write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
600 {
601 return (
602 format!("ERROR: cannot create backup {}: {e}", bp.display()),
603 CacheEffect::None,
604 );
605 }
606 Some(bp.to_string_lossy().to_string())
607 } else {
608 None
609 }
610 } else {
611 None
612 };
613
614 let perms = preimage.as_ref().map(|p| &p.permissions);
615 if let Err(e) = write_atomic_bytes_with_permissions(path, content.as_bytes(), perms) {
616 return (e, CacheEffect::None);
617 }
618
619 let lines = content.lines().count();
620 let tokens = count_tokens(content);
621 let short = path.file_name().map_or_else(
622 || path.to_string_lossy().to_string(),
623 |f| f.to_string_lossy().to_string(),
624 );
625
626 let mut out = format!("✓ created {short}: {lines} lines, {tokens} tok");
627 if let Some(bp) = backup_path {
628 out.push_str(&format!("\nbackup: {bp}"));
629 }
630 (out, CacheEffect::Invalidate)
631}
632
633fn trim_trailing_per_line(s: &str) -> String {
634 s.lines().map(str::trim_end).collect::<Vec<_>>().join("\n")
635}
636
637fn adapt_new_string_to_line_sep(s: &str, sep: &str) -> String {
638 let normalized = s.replace("\r\n", "\n");
639 if sep == "\r\n" {
640 normalized.replace('\n', "\r\n")
641 } else {
642 normalized
643 }
644}
645
646fn find_original_span(content: &str, normalized_needle: &str) -> Option<String> {
649 let needle_lines: Vec<&str> = normalized_needle.lines().collect();
650 if needle_lines.is_empty() {
651 return None;
652 }
653
654 let content_lines: Vec<&str> = content.lines().collect();
655
656 'outer: for start in 0..content_lines.len() {
657 if start + needle_lines.len() > content_lines.len() {
658 break;
659 }
660 for (i, nl) in needle_lines.iter().enumerate() {
661 if content_lines[start + i].trim_end() != *nl {
662 continue 'outer;
663 }
664 }
665 let sep = if content.contains("\r\n") {
666 "\r\n"
667 } else {
668 "\n"
669 };
670 return Some(content_lines[start..start + needle_lines.len()].join(sep));
671 }
672 None
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678 use std::io::Write;
679 use tempfile::NamedTempFile;
680
681 fn make_temp(content: &str) -> NamedTempFile {
682 let mut f = NamedTempFile::new().unwrap();
683 f.write_all(content.as_bytes()).unwrap();
684 f
685 }
686
687 fn mk_params(path: &Path, old: &str, new: &str, replace_all: bool, create: bool) -> EditParams {
688 EditParams {
689 path: path.to_string_lossy().to_string(),
690 old_string: old.to_string(),
691 new_string: new.to_string(),
692 replace_all,
693 create,
694 expected_md5: None,
695 expected_size: None,
696 expected_mtime_ms: None,
697 backup: false,
698 backup_path: None,
699 evidence: false,
700 diff_max_lines: 200,
701 allow_lossy_utf8: false,
702 }
703 }
704
705 #[test]
706 fn replace_single_occurrence() {
707 let f = make_temp("fn hello() {\n println!(\"hello\");\n}\n");
708 let mut cache = SessionCache::new();
709 let result = handle(
710 &mut cache,
711 &mk_params(f.path(), "hello", "world", false, false),
712 );
713 assert!(result.contains("ERROR"), "should fail: 'hello' appears 2x");
714 }
715
716 #[test]
717 fn replace_all() {
718 let f = make_temp("aaa bbb aaa\n");
719 let mut cache = SessionCache::new();
720 let result = handle(&mut cache, &mk_params(f.path(), "aaa", "ccc", true, false));
721 assert!(result.contains("2 replacements"));
722 let content = std::fs::read_to_string(f.path()).unwrap();
723 assert_eq!(content, "ccc bbb ccc\n");
724 }
725
726 #[test]
727 fn not_found_error() {
728 let f = make_temp("some content\n");
729 let mut cache = SessionCache::new();
730 let result = handle(
731 &mut cache,
732 &mk_params(f.path(), "nonexistent", "x", false, false),
733 );
734 assert!(result.contains("ERROR: old_string not found"));
735 }
736
737 #[test]
738 fn create_new_file() {
739 let dir = tempfile::tempdir().unwrap();
740 let path = dir.path().join("sub/new_file.txt");
741 let mut cache = SessionCache::new();
742 let result = handle(
743 &mut cache,
744 &mk_params(&path, "", "line1\nline2\nline3\n", false, true),
745 );
746 assert!(result.contains("created new_file.txt"));
747 assert!(result.contains("3 lines"));
748 assert!(path.exists());
749 }
750
751 #[cfg(not(feature = "no-jail"))]
754 #[test]
755 fn create_denied_in_read_only_root() {
756 let _iso = crate::core::data_dir::isolated_data_dir();
757 let dir = tempfile::tempdir().unwrap();
758 let ro = dir.path().join("refrepo");
759 std::fs::create_dir_all(&ro).unwrap();
760 let path = ro.join("sub/new_file.txt");
761
762 let ro_canon = crate::core::pathjail::canonicalize_or_self(&ro);
763 crate::test_env::set_var(
764 "LEAN_CTX_READ_ONLY_ROOTS",
765 ro_canon.to_string_lossy().as_ref(),
766 );
767 let mut cache = SessionCache::new();
768 let result = handle(&mut cache, &mk_params(&path, "", "x\n", false, true));
769 crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
770
771 assert!(
772 result.contains("read-only"),
773 "create in a read-only root must be refused: {result}"
774 );
775 assert!(!path.exists(), "no file may be created in a read-only root");
776 assert!(
777 !ro.join("sub").exists(),
778 "no directory may be created in a read-only root"
779 );
780 }
781
782 #[cfg(not(feature = "no-jail"))]
786 #[test]
787 fn edit_denied_in_read_only_root() {
788 let _iso = crate::core::data_dir::isolated_data_dir();
789 let dir = tempfile::tempdir().unwrap();
790 let ro = dir.path().join("refrepo");
791 std::fs::create_dir_all(&ro).unwrap();
792 let path = ro.join("a.txt");
793 std::fs::write(&path, "alpha beta\n").unwrap();
794
795 let ro_canon = crate::core::pathjail::canonicalize_or_self(&ro);
796 crate::test_env::set_var(
797 "LEAN_CTX_READ_ONLY_ROOTS",
798 ro_canon.to_string_lossy().as_ref(),
799 );
800 let mut cache = SessionCache::new();
801 let result = handle(
802 &mut cache,
803 &mk_params(&path, "alpha", "OMEGA", false, false),
804 );
805 crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
806
807 assert!(
808 result.contains("read-only"),
809 "edit in a read-only root must be refused: {result}"
810 );
811 assert_eq!(
812 std::fs::read_to_string(&path).unwrap(),
813 "alpha beta\n",
814 "the file must be left untouched"
815 );
816 }
817
818 #[cfg(not(feature = "no-jail"))]
824 #[test]
825 fn backup_path_cannot_smuggle_writes_into_read_only_root() {
826 let _iso = crate::core::data_dir::isolated_data_dir();
827 let dir = tempfile::tempdir().unwrap();
828 let ro = dir.path().join("refrepo");
829 let work = dir.path().join("work");
830 std::fs::create_dir_all(&ro).unwrap();
831 std::fs::create_dir_all(&work).unwrap();
832 let target = work.join("a.txt"); std::fs::write(&target, "alpha beta\n").unwrap();
834 let smuggled = ro.join("leak.bak"); let ro_canon = crate::core::pathjail::canonicalize_or_self(&ro);
837 crate::test_env::set_var(
838 "LEAN_CTX_READ_ONLY_ROOTS",
839 ro_canon.to_string_lossy().as_ref(),
840 );
841 let mut params = mk_params(&target, "alpha", "OMEGA", false, false);
842 params.backup = true;
843 params.backup_path = Some(smuggled.to_string_lossy().to_string());
844 let mut cache = SessionCache::new();
845 let result = handle(&mut cache, ¶ms);
846 crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
847
848 assert!(
849 result.contains("read-only"),
850 "a backup_path into a read-only root must be refused: {result}"
851 );
852 assert!(
853 !smuggled.exists(),
854 "no backup may be smuggled into a read-only root"
855 );
856 assert_eq!(
857 std::fs::read_to_string(&target).unwrap(),
858 "alpha beta\n",
859 "fail-closed: the writable target must be untouched when the backup is denied"
860 );
861 }
862
863 #[cfg(not(feature = "no-jail"))]
868 #[test]
869 fn edit_denied_via_config_read_only_roots() {
870 let _iso = crate::core::data_dir::isolated_data_dir();
871 let dir = tempfile::tempdir().unwrap();
872 let ro = dir.path().join("refrepo");
873 std::fs::create_dir_all(&ro).unwrap();
874 let path = ro.join("a.txt");
875 std::fs::write(&path, "alpha beta\n").unwrap();
876
877 let cfg_path = crate::core::config::Config::path().unwrap();
879 if let Some(parent) = cfg_path.parent() {
880 std::fs::create_dir_all(parent).unwrap();
881 }
882 std::fs::write(
884 &cfg_path,
885 format!("read_only_roots = ['{}']\n", ro.to_string_lossy()),
886 )
887 .unwrap();
888
889 let mut cache = SessionCache::new();
890 let result = handle(
891 &mut cache,
892 &mk_params(&path, "alpha", "OMEGA", false, false),
893 );
894
895 assert!(
896 result.contains("read-only"),
897 "config-declared read_only_roots must deny the edit: {result}"
898 );
899 assert_eq!(
900 std::fs::read_to_string(&path).unwrap(),
901 "alpha beta\n",
902 "the file must be left untouched"
903 );
904 }
905
906 #[cfg(unix)]
913 #[test]
914 fn write_falls_back_on_readonly_parent_dir() {
915 use std::os::unix::fs::PermissionsExt;
916
917 if unsafe { libc::geteuid() } == 0 {
919 return;
920 }
921
922 let dir = tempfile::tempdir().unwrap();
923 let path = dir.path().join("opencode.jsonc");
924 std::fs::write(&path, b"hello").unwrap();
925
926 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap();
929
930 let res = write_atomic_bytes_with_permissions(&path, b"world", None);
931
932 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
934
935 assert!(res.is_ok(), "in-place fallback should succeed: {res:?}");
936 assert_eq!(std::fs::read(&path).unwrap(), b"world");
937 }
938
939 #[cfg(unix)]
942 #[test]
943 fn handle_edit_succeeds_on_readonly_parent_dir() {
944 use std::os::unix::fs::PermissionsExt;
945
946 if unsafe { libc::geteuid() } == 0 {
948 return;
949 }
950
951 let dir = tempfile::tempdir().unwrap();
952 let path = dir.path().join("opencode.jsonc");
953 std::fs::write(&path, "hello world\n").unwrap();
954 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap();
955
956 let mut cache = SessionCache::new();
957 let result = handle(
958 &mut cache,
959 &mk_params(&path, "hello", "goodbye", false, false),
960 );
961
962 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
963
964 assert!(
965 result.contains('✓'),
966 "edit should succeed via in-place fallback: {result}"
967 );
968 assert_eq!(std::fs::read_to_string(&path).unwrap(), "goodbye world\n");
969 }
970
971 #[test]
972 fn unique_match_succeeds() {
973 let f = make_temp("fn main() {\n let x = 42;\n}\n");
974 let mut cache = SessionCache::new();
975 let result = handle(
976 &mut cache,
977 &mk_params(f.path(), "let x = 42", "let x = 99", false, false),
978 );
979 assert!(result.contains("✓"));
980 assert!(result.contains("1 replacement"));
981 let content = std::fs::read_to_string(f.path()).unwrap();
982 assert!(content.contains("let x = 99"));
983 }
984
985 #[test]
986 fn crlf_file_with_lf_search() {
987 let f = make_temp("line1\r\nline2\r\nline3\r\n");
988 let mut cache = SessionCache::new();
989 let result = handle(
990 &mut cache,
991 &mk_params(f.path(), "line1\nline2", "changed1\nchanged2", false, false),
992 );
993 assert!(result.contains("✓"), "CRLF fallback should work: {result}");
994 let content = std::fs::read_to_string(f.path()).unwrap();
995 assert!(
996 content.contains("changed1\r\nchanged2"),
997 "new_string should be adapted to CRLF: {content:?}"
998 );
999 assert!(
1000 content.contains("\r\nline3\r\n"),
1001 "rest of file should keep CRLF: {content:?}"
1002 );
1003 }
1004
1005 #[test]
1006 fn lf_file_with_crlf_search() {
1007 let f = make_temp("line1\nline2\nline3\n");
1008 let mut cache = SessionCache::new();
1009 let result = handle(
1010 &mut cache,
1011 &mk_params(f.path(), "line1\r\nline2", "a\r\nb", false, false),
1012 );
1013 assert!(result.contains("✓"), "LF fallback should work: {result}");
1014 let content = std::fs::read_to_string(f.path()).unwrap();
1015 assert!(
1016 content.contains("a\nb"),
1017 "new_string should be adapted to LF: {content:?}"
1018 );
1019 }
1020
1021 #[test]
1022 fn trailing_whitespace_tolerance() {
1023 let f = make_temp(" let x = 1; \n let y = 2;\n");
1024 let mut cache = SessionCache::new();
1025 let result = handle(
1026 &mut cache,
1027 &mk_params(
1028 f.path(),
1029 " let x = 1;\n let y = 2;",
1030 " let x = 10;\n let y = 20;",
1031 false,
1032 false,
1033 ),
1034 );
1035 assert!(
1036 result.contains("✓"),
1037 "trailing whitespace tolerance should work: {result}"
1038 );
1039 let content = std::fs::read_to_string(f.path()).unwrap();
1040 assert!(content.contains("let x = 10;"));
1041 assert!(content.contains("let y = 20;"));
1042 }
1043
1044 #[test]
1045 fn crlf_with_trailing_whitespace() {
1046 let f = make_temp(" const a = 1; \r\n const b = 2;\r\n");
1047 let mut cache = SessionCache::new();
1048 let result = handle(
1049 &mut cache,
1050 &mk_params(
1051 f.path(),
1052 " const a = 1;\n const b = 2;",
1053 " const a = 10;\n const b = 20;",
1054 false,
1055 false,
1056 ),
1057 );
1058 assert!(
1059 result.contains("✓"),
1060 "CRLF + trailing whitespace should work: {result}"
1061 );
1062 let content = std::fs::read_to_string(f.path()).unwrap();
1063 assert!(content.contains("const a = 10;"));
1064 assert!(content.contains("const b = 20;"));
1065 }
1066
1067 #[test]
1068 fn rejects_invalid_utf8_by_default() {
1069 let mut f = NamedTempFile::new().unwrap();
1070 f.write_all(&[0xff, 0xfe, 0xfd]).unwrap();
1071 let mut cache = SessionCache::new();
1072 let result = handle(&mut cache, &mk_params(f.path(), "a", "b", false, false));
1073 assert!(
1074 result.contains("not valid UTF-8"),
1075 "expected utf8 rejection, got: {result}"
1076 );
1077 }
1078
1079 #[test]
1080 fn allows_lossy_utf8_only_when_enabled() {
1081 let mut f = NamedTempFile::new().unwrap();
1082 f.write_all(&[0xff, 0xfe, 0xfd]).unwrap();
1083 let mut cache = SessionCache::new();
1084 let mut p = mk_params(f.path(), "a", "b", false, false);
1085 p.allow_lossy_utf8 = true;
1086 let result = handle(&mut cache, &p);
1087 assert!(
1088 !result.contains("not valid UTF-8"),
1089 "lossy mode should avoid utf8 hard error, got: {result}"
1090 );
1091 }
1092
1093 #[test]
1094 fn expected_md5_mismatch_fails_without_writing() {
1095 let f = make_temp("aaa\n");
1096 let mut cache = SessionCache::new();
1097 let mut p = mk_params(f.path(), "aaa", "bbb", false, false);
1098 p.expected_md5 = Some("deadbeef".to_string());
1099 let result = handle(&mut cache, &p);
1100 assert!(
1101 result.contains("preimage mismatch"),
1102 "expected preimage mismatch, got: {result}"
1103 );
1104 let content = std::fs::read_to_string(f.path()).unwrap();
1105 assert_eq!(content, "aaa\n");
1106 }
1107
1108 #[test]
1109 fn backup_is_created_when_enabled() {
1110 let f = make_temp("aaa\n");
1111 let mut cache = SessionCache::new();
1112 let mut p = mk_params(f.path(), "aaa", "bbb", false, false);
1113 p.backup = true;
1114 let out = handle(&mut cache, &p);
1115 assert!(out.contains("backup:"), "expected backup path, got: {out}");
1116 let bp = out
1117 .lines()
1118 .find_map(|l| l.strip_prefix("backup: "))
1119 .expect("backup line");
1120 let backup_content = std::fs::read_to_string(bp).unwrap();
1121 assert_eq!(backup_content, "aaa\n");
1122 let content = std::fs::read_to_string(f.path()).unwrap();
1123 assert_eq!(content, "bbb\n");
1124 }
1125
1126 #[test]
1127 fn evidence_diff_is_emitted_when_enabled() {
1128 let f = make_temp("line1\nline2\n");
1129 let mut cache = SessionCache::new();
1130 let mut p = mk_params(f.path(), "line2", "changed2", false, false);
1131 p.evidence = true;
1132 p.diff_max_lines = 50;
1133 let out = handle(&mut cache, &p);
1134 assert!(out.contains("```diff"), "expected diff fence, got: {out}");
1135 assert!(
1136 out.contains("preimage:"),
1137 "expected preimage metadata, got: {out}"
1138 );
1139 assert!(
1140 out.contains("postimage:"),
1141 "expected postimage metadata, got: {out}"
1142 );
1143 }
1144
1145 #[test]
1149 fn run_io_success_reports_invalidate_effect() {
1150 let f = make_temp("fn main() {\n let x = 42;\n}\n");
1151 let (text, effect) = run_io(
1152 &mk_params(f.path(), "let x = 42", "let x = 99", false, false),
1153 "",
1154 );
1155 assert!(text.contains("✓"), "expected success: {text}");
1156 assert!(
1157 matches!(effect, CacheEffect::Invalidate),
1158 "successful edit must invalidate the cache entry"
1159 );
1160 let content = std::fs::read_to_string(f.path()).unwrap();
1161 assert!(content.contains("let x = 99"));
1162 }
1163
1164 #[test]
1165 fn run_io_failure_reports_no_cache_effect() {
1166 let f = make_temp("some content\n");
1167 let (text, effect) = run_io(&mk_params(f.path(), "nonexistent", "x", false, false), "");
1168 assert!(text.contains("ERROR: old_string not found"));
1169 assert!(
1170 matches!(effect, CacheEffect::None),
1171 "a failed edit must not mutate the cache"
1172 );
1173 }
1174
1175 #[test]
1179 fn run_io_concurrent_edits_to_different_files_all_succeed() {
1180 use std::sync::Arc;
1181 let dir = Arc::new(tempfile::tempdir().unwrap());
1182 let n = 16;
1183 let mut paths = Vec::new();
1184 for i in 0..n {
1185 let p = dir.path().join(format!("file_{i}.txt"));
1186 std::fs::write(&p, format!("value = {i}\n")).unwrap();
1187 paths.push(p);
1188 }
1189 let barrier = Arc::new(std::sync::Barrier::new(n));
1190 let mut handles = Vec::new();
1191 for (i, p) in paths.into_iter().enumerate() {
1192 let barrier = Arc::clone(&barrier);
1193 handles.push(std::thread::spawn(move || {
1194 barrier.wait();
1195 let (text, effect) = run_io(
1196 &mk_params(
1197 &p,
1198 &format!("value = {i}"),
1199 &format!("value = {}", i + 1000),
1200 false,
1201 false,
1202 ),
1203 "",
1204 );
1205 assert!(text.contains("✓"), "edit {i} failed: {text}");
1206 assert!(matches!(effect, CacheEffect::Invalidate));
1207 (p, i)
1208 }));
1209 }
1210 for h in handles {
1211 let (p, i) = h.join().unwrap();
1212 let content = std::fs::read_to_string(&p).unwrap();
1213 assert_eq!(content, format!("value = {}\n", i + 1000));
1214 }
1215 }
1216
1217 #[test]
1218 fn run_io_escalation_reports_store_full_effect() {
1219 let f = make_temp("line a\nline b\nline c\n");
1223 let (text, effect) = run_io(
1224 &mk_params(f.path(), "definitely-not-present", "x", false, false),
1225 "signatures",
1226 );
1227 assert!(
1228 text.contains("[auto-escalation]"),
1229 "expected escalation: {text}"
1230 );
1231 match effect {
1232 CacheEffect::StoreFull(content) => {
1233 assert!(content.contains("line a") && content.contains("line c"));
1234 }
1235 _ => panic!("escalation must report a StoreFull cache effect"),
1236 }
1237 }
1238
1239 #[test]
1240 fn apply_cache_effect_invalidate_and_store() {
1241 let f = make_temp("hello\n");
1242 let mut cache = SessionCache::new();
1243 cache.store(&f.path().to_string_lossy(), "hello\n");
1244 apply_cache_effect(
1245 &mut cache,
1246 &f.path().to_string_lossy(),
1247 CacheEffect::Invalidate,
1248 );
1249 assert!(
1250 cache.get(&f.path().to_string_lossy()).is_none(),
1251 "Invalidate must drop the entry"
1252 );
1253 apply_cache_effect(
1254 &mut cache,
1255 &f.path().to_string_lossy(),
1256 CacheEffect::StoreFull("fresh\n".to_string()),
1257 );
1258 assert!(
1259 cache.get(&f.path().to_string_lossy()).is_some(),
1260 "StoreFull must re-populate the entry"
1261 );
1262 }
1263
1264 #[test]
1265 fn identical_old_new_rejected() {
1266 let f = make_temp("fn main() {}\n");
1267 let mut cache = SessionCache::new();
1268 let result = handle(
1269 &mut cache,
1270 &mk_params(f.path(), "fn main() {}", "fn main() {}", false, false),
1271 );
1272 assert!(result.contains("identical"));
1273 }
1274
1275 #[test]
1276 fn edit_already_applied_detected() {
1277 let f = make_temp("fn updated() {}\n");
1278 let (text, effect) = run_io(
1279 &mk_params(
1280 f.path(),
1281 "fn original() {}",
1282 "fn updated() {}",
1283 false,
1284 false,
1285 ),
1286 "",
1287 );
1288 assert!(text.contains("already exists"));
1289 assert!(text.contains("already applied"));
1290 assert!(matches!(effect, CacheEffect::None));
1291 }
1292
1293 #[test]
1294 fn closest_line_hint_shown() {
1295 let f = make_temp(" fn hello() {\n println!(\"hi\");\n }\n");
1296 let (text, _) = run_io(
1297 &mk_params(f.path(), "fn hello(){", "fn hello_world(){", false, false),
1298 "",
1299 );
1300 assert!(text.contains("Closest match at line"));
1301 }
1302
1303 #[test]
1304 fn missing_file_suggests_relocated_path() {
1305 let dir = tempfile::tempdir().unwrap();
1306 std::fs::create_dir_all(dir.path().join(".git")).unwrap();
1307 std::fs::create_dir_all(dir.path().join("src/new")).unwrap();
1308 std::fs::write(dir.path().join("src/new/gizmo.rs"), "fn gizmo() {}\n").unwrap();
1309
1310 let (text, effect) = run_io(
1311 &mk_params(
1312 &dir.path().join("src/old/gizmo.rs"),
1313 "fn gizmo() {}",
1314 "fn gizmo2() {}",
1315 false,
1316 false,
1317 ),
1318 "",
1319 );
1320 assert!(text.contains("same-named file was found"), "got: {text}");
1321 assert!(text.contains("gizmo.rs"), "got: {text}");
1322 assert!(matches!(effect, CacheEffect::None));
1323 }
1324
1325 #[test]
1326 fn old_string_in_other_file_is_reported() {
1327 let dir = tempfile::tempdir().unwrap();
1328 std::fs::create_dir_all(dir.path().join(".git")).unwrap();
1329 let target = dir.path().join("a.rs");
1330 std::fs::write(&target, "fn unrelated_a() {}\n").unwrap();
1331 std::fs::write(dir.path().join("b.rs"), "fn the_target_symbol() {}\n").unwrap();
1332
1333 let (text, _) = run_io(
1334 &mk_params(
1335 &target,
1336 "fn the_target_symbol() {}",
1337 "fn renamed() {}",
1338 false,
1339 false,
1340 ),
1341 "",
1342 );
1343 assert!(text.contains("matching line exists in"), "got: {text}");
1344 assert!(text.contains("b.rs"), "got: {text}");
1345 }
1346
1347 #[cfg(unix)]
1350 #[test]
1351 fn editing_through_a_symlink_is_rejected() {
1352 let dir = tempfile::tempdir().unwrap();
1353 let real = dir.path().join("real.rs");
1354 std::fs::write(&real, "fn old() {}\n").unwrap();
1355 let link = dir.path().join("link.rs");
1356 std::os::unix::fs::symlink(&real, &link).unwrap();
1357
1358 let (text, effect) = run_io(
1359 &mk_params(&link, "fn old() {}", "fn new() {}", false, false),
1360 "",
1361 );
1362 assert!(text.contains("symlink"), "got: {text}");
1363 assert!(matches!(effect, CacheEffect::None));
1364 assert_eq!(std::fs::read_to_string(&real).unwrap(), "fn old() {}\n");
1366 }
1367
1368 #[cfg(unix)]
1371 #[test]
1372 fn creating_over_a_symlink_is_rejected() {
1373 let dir = tempfile::tempdir().unwrap();
1374 let real = dir.path().join("victim.txt");
1375 std::fs::write(&real, "precious").unwrap();
1376 let link = dir.path().join("innocent.txt");
1377 std::os::unix::fs::symlink(&real, &link).unwrap();
1378
1379 let (text, _) = run_io(&mk_params(&link, "", "overwritten", false, true), "");
1380 assert!(
1381 text.contains("symlink") || text.contains("ERROR"),
1382 "got: {text}"
1383 );
1384 assert_eq!(
1385 std::fs::read_to_string(&real).unwrap(),
1386 "precious",
1387 "symlink target must not be modified"
1388 );
1389 }
1390
1391 #[test]
1392 fn regular_file_edit_still_works_after_symlink_guard() {
1393 let dir = tempfile::tempdir().unwrap();
1394 let file = dir.path().join("normal.rs");
1395 std::fs::write(&file, "fn old() {}\n").unwrap();
1396
1397 let (text, _) = run_io(
1398 &mk_params(&file, "fn old() {}", "fn new() {}", false, false),
1399 "",
1400 );
1401 assert!(
1402 text.contains("Edit applied") || !text.starts_with("ERROR"),
1403 "got: {text}"
1404 );
1405 assert_eq!(std::fs::read_to_string(&file).unwrap(), "fn new() {}\n");
1406 }
1407}