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 fn handle(cache: &mut SessionCache, params: &EditParams) -> String {
332 let file_path = ¶ms.path;
333
334 if params.create {
335 return handle_create(cache, file_path, ¶ms.new_string, params);
336 }
337
338 let cap = crate::core::limits::max_read_bytes();
339 let path = Path::new(file_path);
340 let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
341 Ok(p) => p,
342 Err(e) => return e,
343 };
344 if let Err(e) = verify_expected_preimage(&pre, params) {
345 return e;
346 }
347 let content = &pre.text;
348
349 if params.old_string.is_empty() {
350 return "ERROR: old_string must not be empty (use create=true to create a new file)".into();
351 }
352
353 let uses_crlf = pre.uses_crlf;
354 let old_str = ¶ms.old_string;
355 let new_str = ¶ms.new_string;
356
357 let occurrences = content.matches(old_str).count();
358
359 if occurrences > 0 {
360 let args = ReplaceArgs {
361 content,
362 old_str,
363 new_str,
364 occurrences,
365 replace_all: params.replace_all,
366 old_tokens: count_tokens(¶ms.old_string),
367 new_tokens: count_tokens(¶ms.new_string),
368 };
369 return do_replace(cache, path, &pre, params, cap, &args);
370 }
371
372 if uses_crlf && !old_str.contains('\r') {
374 let old_crlf = old_str.replace('\n', "\r\n");
375 let occ = content.matches(&old_crlf).count();
376 if occ > 0 {
377 let new_crlf = new_str.replace('\n', "\r\n");
378 let args = ReplaceArgs {
379 content,
380 old_str: &old_crlf,
381 new_str: &new_crlf,
382 occurrences: occ,
383 replace_all: params.replace_all,
384 old_tokens: count_tokens(¶ms.old_string),
385 new_tokens: count_tokens(¶ms.new_string),
386 };
387 return do_replace(cache, path, &pre, params, cap, &args);
388 }
389 } else if !uses_crlf && old_str.contains("\r\n") {
390 let old_lf = old_str.replace("\r\n", "\n");
391 let occ = content.matches(&old_lf).count();
392 if occ > 0 {
393 let new_lf = new_str.replace("\r\n", "\n");
394 let args = ReplaceArgs {
395 content,
396 old_str: &old_lf,
397 new_str: &new_lf,
398 occurrences: occ,
399 replace_all: params.replace_all,
400 old_tokens: count_tokens(¶ms.old_string),
401 new_tokens: count_tokens(¶ms.new_string),
402 };
403 return do_replace(cache, path, &pre, params, cap, &args);
404 }
405 }
406
407 let normalized_content = trim_trailing_per_line(content);
409 let normalized_old = trim_trailing_per_line(old_str);
410 if !normalized_old.is_empty() && normalized_content.contains(&normalized_old) {
411 let line_sep = if uses_crlf { "\r\n" } else { "\n" };
412 let adapted_new = adapt_new_string_to_line_sep(new_str, line_sep);
413 let adapted_old = find_original_span(content, &normalized_old);
414 if let Some(original_match) = adapted_old {
415 let occ = content.matches(&original_match).count();
416 let args = ReplaceArgs {
417 content,
418 old_str: &original_match,
419 new_str: &adapted_new,
420 occurrences: occ,
421 replace_all: params.replace_all,
422 old_tokens: count_tokens(¶ms.old_string),
423 new_tokens: count_tokens(¶ms.new_string),
424 };
425 return do_replace(cache, path, &pre, params, cap, &args);
426 }
427 }
428
429 let preview = if old_str.len() > 80 {
430 format!("{}...", &old_str[..77])
431 } else {
432 old_str.clone()
433 };
434 let hint = if uses_crlf {
435 " (file uses CRLF line endings)"
436 } else {
437 ""
438 };
439
440 let escalation = auto_escalate_reread(cache, file_path);
441
442 format!(
443 "ERROR: old_string not found in {file_path}{hint}. \
444 Make sure it matches exactly (including whitespace/indentation).\n\
445 Searched for: {preview}{escalation}"
446 )
447}
448
449fn auto_escalate_reread(cache: &mut SessionCache, path: &str) -> String {
453 let entry = cache.get(path);
454 let last_mode = entry.map(|e| e.last_mode.clone()).unwrap_or_default();
455
456 if last_mode.is_empty() || last_mode == "full" {
457 return String::new();
458 }
459
460 let Ok(fresh_content) = std::fs::read_to_string(path) else {
461 return String::new();
462 };
463 cache.store(path, &fresh_content);
464 cache.mark_full_delivered(path);
465
466 let line_count = fresh_content.lines().count();
467 const MAX_LINES: usize = 300;
468
469 let content_preview = if line_count <= MAX_LINES {
470 fresh_content
471 } else {
472 let lines: Vec<&str> = fresh_content.lines().collect();
473 let head = &lines[..MAX_LINES / 2];
474 let tail = &lines[line_count - MAX_LINES / 2..];
475 let omitted = line_count - MAX_LINES;
476 format!(
477 "{}\n[... {omitted} lines omitted ...]\n{}",
478 head.join("\n"),
479 tail.join("\n")
480 )
481 };
482
483 format!(
484 "\n\n[auto-escalation] Last read used mode=\"{last_mode}\". \
485 Full content ({line_count}L) below — retry edit with exact text from here:\n\n{content_preview}"
486 )
487}
488
489fn do_replace(
490 cache: &mut SessionCache,
491 path: &Path,
492 pre: &FilePreimage,
493 params: &EditParams,
494 cap: usize,
495 args: &ReplaceArgs<'_>,
496) -> String {
497 if args.occurrences > 1 && !args.replace_all {
498 return format!(
499 "ERROR: old_string found {} times in {}. \
500 Use replace_all=true to replace all, or provide more context to make old_string unique."
501 ,
502 args.occurrences,
503 path.display()
504 );
505 }
506
507 let new_content = if args.replace_all {
508 args.content.replace(args.old_str, args.new_str)
509 } else {
510 args.content.replacen(args.old_str, args.new_str, 1)
511 };
512
513 if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
514 return e;
515 }
516
517 let backup_path = if params.backup {
518 let bp = params
519 .backup_path
520 .as_deref()
521 .map(PathBuf::from)
522 .or_else(|| default_backup_path(path));
523 let Some(bp) = bp else {
524 return format!("ERROR: cannot compute backup path for {}", path.display());
525 };
526 if let Err(e) = write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
527 {
528 return format!("ERROR: cannot create backup {}: {e}", bp.display());
529 }
530 Some(bp.to_string_lossy().to_string())
531 } else {
532 None
533 };
534
535 if let Err(e) =
536 write_atomic_bytes_with_permissions(path, new_content.as_bytes(), Some(&pre.permissions))
537 {
538 return e;
539 }
540
541 cache.invalidate(¶ms.path);
542
543 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
544 bt.record_edit(¶ms.path);
545 }
546
547 let old_lines = args.content.lines().count();
548 let new_lines = new_content.lines().count();
549 let line_delta = new_lines as i64 - old_lines as i64;
550 let delta_str = if line_delta > 0 {
551 format!("+{line_delta}")
552 } else {
553 format!("{line_delta}")
554 };
555
556 let old_tokens = args.old_tokens;
557 let new_tokens = args.new_tokens;
558
559 let replaced_str = if args.replace_all && args.occurrences > 1 {
560 format!("{} replacements", args.occurrences)
561 } else {
562 "1 replacement".into()
563 };
564
565 let short = path.file_name().map_or_else(
566 || path.to_string_lossy().to_string(),
567 |f| f.to_string_lossy().to_string(),
568 );
569
570 let post_mtime_ms = std::fs::metadata(path)
571 .ok()
572 .and_then(|m| m.modified().ok())
573 .map_or(0, system_time_to_millis);
574 let post_fp = FileFingerprint {
575 size: new_content.len() as u64,
576 mtime_ms: post_mtime_ms,
577 md5: crate::core::hasher::hash_hex(new_content.as_bytes()),
578 };
579
580 let mut out = format!(
581 "✓ {short}: {replaced_str}, {delta_str} lines ({old_tokens}→{new_tokens} tok)\n\
582preimage: bytes={}, mtime_ms={}, md5={}\n\
583postimage: bytes={}, mtime_ms={}, md5={}",
584 pre.fp.size, pre.fp.mtime_ms, pre.fp.md5, post_fp.size, post_fp.mtime_ms, post_fp.md5
585 );
586 if let Some(bp) = backup_path {
587 out.push_str(&format!("\nbackup: {bp}"));
588 }
589 if params.evidence {
590 let diff = build_diff_evidence(args.content, &new_content, &short, params.diff_max_lines);
591 out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
592 out.push_str(&diff);
593 out.push_str("\n```");
594 }
595 out
596}
597
598fn handle_create(
599 cache: &mut SessionCache,
600 file_path: &str,
601 content: &str,
602 params: &EditParams,
603) -> String {
604 let path = Path::new(file_path);
605 let cap = crate::core::limits::max_read_bytes();
606
607 let mut preimage: Option<FilePreimage> = None;
608 if path.exists() {
609 let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
610 Ok(p) => p,
611 Err(e) => return e,
612 };
613 if let Err(e) = verify_expected_preimage(&pre, params) {
614 return e;
615 }
616 if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
617 return e;
618 }
619 preimage = Some(pre);
620 }
621
622 if let Some(parent) = path.parent() {
623 if !parent.exists() {
624 if let Err(e) = std::fs::create_dir_all(parent) {
625 return format!("ERROR: cannot create directory {}: {e}", parent.display());
626 }
627 }
628 }
629
630 let backup_path = if params.backup {
631 if let Some(pre) = &preimage {
632 let bp = params
633 .backup_path
634 .as_deref()
635 .map(PathBuf::from)
636 .or_else(|| default_backup_path(path));
637 let Some(bp) = bp else {
638 return format!("ERROR: cannot compute backup path for {}", path.display());
639 };
640 if let Err(e) =
641 write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
642 {
643 return format!("ERROR: cannot create backup {}: {e}", bp.display());
644 }
645 Some(bp.to_string_lossy().to_string())
646 } else {
647 None
648 }
649 } else {
650 None
651 };
652
653 let perms = preimage.as_ref().map(|p| &p.permissions);
654 if let Err(e) = write_atomic_bytes_with_permissions(path, content.as_bytes(), perms) {
655 return e;
656 }
657
658 cache.invalidate(file_path);
659
660 let lines = content.lines().count();
661 let tokens = count_tokens(content);
662 let short = path.file_name().map_or_else(
663 || path.to_string_lossy().to_string(),
664 |f| f.to_string_lossy().to_string(),
665 );
666
667 let mut out = format!("✓ created {short}: {lines} lines, {tokens} tok");
668 if let Some(bp) = backup_path {
669 out.push_str(&format!("\nbackup: {bp}"));
670 }
671 out
672}
673
674fn trim_trailing_per_line(s: &str) -> String {
675 s.lines().map(str::trim_end).collect::<Vec<_>>().join("\n")
676}
677
678fn adapt_new_string_to_line_sep(s: &str, sep: &str) -> String {
679 let normalized = s.replace("\r\n", "\n");
680 if sep == "\r\n" {
681 normalized.replace('\n', "\r\n")
682 } else {
683 normalized
684 }
685}
686
687fn find_original_span(content: &str, normalized_needle: &str) -> Option<String> {
690 let needle_lines: Vec<&str> = normalized_needle.lines().collect();
691 if needle_lines.is_empty() {
692 return None;
693 }
694
695 let content_lines: Vec<&str> = content.lines().collect();
696
697 'outer: for start in 0..content_lines.len() {
698 if start + needle_lines.len() > content_lines.len() {
699 break;
700 }
701 for (i, nl) in needle_lines.iter().enumerate() {
702 if content_lines[start + i].trim_end() != *nl {
703 continue 'outer;
704 }
705 }
706 let sep = if content.contains("\r\n") {
707 "\r\n"
708 } else {
709 "\n"
710 };
711 return Some(content_lines[start..start + needle_lines.len()].join(sep));
712 }
713 None
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719 use std::io::Write;
720 use tempfile::NamedTempFile;
721
722 fn make_temp(content: &str) -> NamedTempFile {
723 let mut f = NamedTempFile::new().unwrap();
724 f.write_all(content.as_bytes()).unwrap();
725 f
726 }
727
728 fn mk_params(path: &Path, old: &str, new: &str, replace_all: bool, create: bool) -> EditParams {
729 EditParams {
730 path: path.to_string_lossy().to_string(),
731 old_string: old.to_string(),
732 new_string: new.to_string(),
733 replace_all,
734 create,
735 expected_md5: None,
736 expected_size: None,
737 expected_mtime_ms: None,
738 backup: false,
739 backup_path: None,
740 evidence: false,
741 diff_max_lines: 200,
742 allow_lossy_utf8: false,
743 }
744 }
745
746 #[test]
747 fn replace_single_occurrence() {
748 let f = make_temp("fn hello() {\n println!(\"hello\");\n}\n");
749 let mut cache = SessionCache::new();
750 let result = handle(
751 &mut cache,
752 &mk_params(f.path(), "hello", "world", false, false),
753 );
754 assert!(result.contains("ERROR"), "should fail: 'hello' appears 2x");
755 }
756
757 #[test]
758 fn replace_all() {
759 let f = make_temp("aaa bbb aaa\n");
760 let mut cache = SessionCache::new();
761 let result = handle(&mut cache, &mk_params(f.path(), "aaa", "ccc", true, false));
762 assert!(result.contains("2 replacements"));
763 let content = std::fs::read_to_string(f.path()).unwrap();
764 assert_eq!(content, "ccc bbb ccc\n");
765 }
766
767 #[test]
768 fn not_found_error() {
769 let f = make_temp("some content\n");
770 let mut cache = SessionCache::new();
771 let result = handle(
772 &mut cache,
773 &mk_params(f.path(), "nonexistent", "x", false, false),
774 );
775 assert!(result.contains("ERROR: old_string not found"));
776 }
777
778 #[test]
779 fn create_new_file() {
780 let dir = tempfile::tempdir().unwrap();
781 let path = dir.path().join("sub/new_file.txt");
782 let mut cache = SessionCache::new();
783 let result = handle(
784 &mut cache,
785 &mk_params(&path, "", "line1\nline2\nline3\n", false, true),
786 );
787 assert!(result.contains("created new_file.txt"));
788 assert!(result.contains("3 lines"));
789 assert!(path.exists());
790 }
791
792 #[test]
793 fn unique_match_succeeds() {
794 let f = make_temp("fn main() {\n let x = 42;\n}\n");
795 let mut cache = SessionCache::new();
796 let result = handle(
797 &mut cache,
798 &mk_params(f.path(), "let x = 42", "let x = 99", false, false),
799 );
800 assert!(result.contains("✓"));
801 assert!(result.contains("1 replacement"));
802 let content = std::fs::read_to_string(f.path()).unwrap();
803 assert!(content.contains("let x = 99"));
804 }
805
806 #[test]
807 fn crlf_file_with_lf_search() {
808 let f = make_temp("line1\r\nline2\r\nline3\r\n");
809 let mut cache = SessionCache::new();
810 let result = handle(
811 &mut cache,
812 &mk_params(f.path(), "line1\nline2", "changed1\nchanged2", false, false),
813 );
814 assert!(result.contains("✓"), "CRLF fallback should work: {result}");
815 let content = std::fs::read_to_string(f.path()).unwrap();
816 assert!(
817 content.contains("changed1\r\nchanged2"),
818 "new_string should be adapted to CRLF: {content:?}"
819 );
820 assert!(
821 content.contains("\r\nline3\r\n"),
822 "rest of file should keep CRLF: {content:?}"
823 );
824 }
825
826 #[test]
827 fn lf_file_with_crlf_search() {
828 let f = make_temp("line1\nline2\nline3\n");
829 let mut cache = SessionCache::new();
830 let result = handle(
831 &mut cache,
832 &mk_params(f.path(), "line1\r\nline2", "a\r\nb", false, false),
833 );
834 assert!(result.contains("✓"), "LF fallback should work: {result}");
835 let content = std::fs::read_to_string(f.path()).unwrap();
836 assert!(
837 content.contains("a\nb"),
838 "new_string should be adapted to LF: {content:?}"
839 );
840 }
841
842 #[test]
843 fn trailing_whitespace_tolerance() {
844 let f = make_temp(" let x = 1; \n let y = 2;\n");
845 let mut cache = SessionCache::new();
846 let result = handle(
847 &mut cache,
848 &mk_params(
849 f.path(),
850 " let x = 1;\n let y = 2;",
851 " let x = 10;\n let y = 20;",
852 false,
853 false,
854 ),
855 );
856 assert!(
857 result.contains("✓"),
858 "trailing whitespace tolerance should work: {result}"
859 );
860 let content = std::fs::read_to_string(f.path()).unwrap();
861 assert!(content.contains("let x = 10;"));
862 assert!(content.contains("let y = 20;"));
863 }
864
865 #[test]
866 fn crlf_with_trailing_whitespace() {
867 let f = make_temp(" const a = 1; \r\n const b = 2;\r\n");
868 let mut cache = SessionCache::new();
869 let result = handle(
870 &mut cache,
871 &mk_params(
872 f.path(),
873 " const a = 1;\n const b = 2;",
874 " const a = 10;\n const b = 20;",
875 false,
876 false,
877 ),
878 );
879 assert!(
880 result.contains("✓"),
881 "CRLF + trailing whitespace should work: {result}"
882 );
883 let content = std::fs::read_to_string(f.path()).unwrap();
884 assert!(content.contains("const a = 10;"));
885 assert!(content.contains("const b = 20;"));
886 }
887
888 #[test]
889 fn rejects_invalid_utf8_by_default() {
890 let mut f = NamedTempFile::new().unwrap();
891 f.write_all(&[0xff, 0xfe, 0xfd]).unwrap();
892 let mut cache = SessionCache::new();
893 let result = handle(&mut cache, &mk_params(f.path(), "a", "b", false, false));
894 assert!(
895 result.contains("not valid UTF-8"),
896 "expected utf8 rejection, got: {result}"
897 );
898 }
899
900 #[test]
901 fn allows_lossy_utf8_only_when_enabled() {
902 let mut f = NamedTempFile::new().unwrap();
903 f.write_all(&[0xff, 0xfe, 0xfd]).unwrap();
904 let mut cache = SessionCache::new();
905 let mut p = mk_params(f.path(), "a", "b", false, false);
906 p.allow_lossy_utf8 = true;
907 let result = handle(&mut cache, &p);
908 assert!(
909 !result.contains("not valid UTF-8"),
910 "lossy mode should avoid utf8 hard error, got: {result}"
911 );
912 }
913
914 #[test]
915 fn expected_md5_mismatch_fails_without_writing() {
916 let f = make_temp("aaa\n");
917 let mut cache = SessionCache::new();
918 let mut p = mk_params(f.path(), "aaa", "bbb", false, false);
919 p.expected_md5 = Some("deadbeef".to_string());
920 let result = handle(&mut cache, &p);
921 assert!(
922 result.contains("preimage mismatch"),
923 "expected preimage mismatch, got: {result}"
924 );
925 let content = std::fs::read_to_string(f.path()).unwrap();
926 assert_eq!(content, "aaa\n");
927 }
928
929 #[test]
930 fn backup_is_created_when_enabled() {
931 let f = make_temp("aaa\n");
932 let mut cache = SessionCache::new();
933 let mut p = mk_params(f.path(), "aaa", "bbb", false, false);
934 p.backup = true;
935 let out = handle(&mut cache, &p);
936 assert!(out.contains("backup:"), "expected backup path, got: {out}");
937 let bp = out
938 .lines()
939 .find_map(|l| l.strip_prefix("backup: "))
940 .expect("backup line");
941 let backup_content = std::fs::read_to_string(bp).unwrap();
942 assert_eq!(backup_content, "aaa\n");
943 let content = std::fs::read_to_string(f.path()).unwrap();
944 assert_eq!(content, "bbb\n");
945 }
946
947 #[test]
948 fn evidence_diff_is_emitted_when_enabled() {
949 let f = make_temp("line1\nline2\n");
950 let mut cache = SessionCache::new();
951 let mut p = mk_params(f.path(), "line2", "changed2", false, false);
952 p.evidence = true;
953 p.diff_max_lines = 50;
954 let out = handle(&mut cache, &p);
955 assert!(out.contains("```diff"), "expected diff fence, got: {out}");
956 assert!(
957 out.contains("preimage:"),
958 "expected preimage metadata, got: {out}"
959 );
960 assert!(
961 out.contains("postimage:"),
962 "expected postimage metadata, got: {out}"
963 );
964 }
965
966 #[test]
967 fn detects_toctou_via_preimage_guard() {
968 let f = make_temp("aaa\n");
969 let cap = crate::core::limits::max_read_bytes();
970 let pre = read_preimage(f.path(), cap, false).unwrap();
971 std::fs::write(f.path(), "bbb\n").unwrap();
972 let err = ensure_preimage_still_matches(f.path(), &pre.fp, cap).unwrap_err();
973 assert!(err.contains("TOCTOU guard"), "unexpected error: {err}");
974 }
975}