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