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