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 md5_hex_bytes(bytes: &[u8]) -> String {
60 use md5::{Digest, Md5};
61 let mut h = Md5::new();
62 h.update(bytes);
63 format!("{:x}", h.finalize())
64}
65
66fn read_file_bytes_limited(
67 path: &Path,
68 cap: usize,
69) -> Result<(Vec<u8>, std::fs::Metadata), String> {
70 if let Ok(meta) = std::fs::metadata(path) {
71 if meta.len() > cap as u64 {
72 return Err(format!(
73 "ERROR: file too large ({} bytes, cap {} via LCTX_MAX_READ_BYTES): {}",
74 meta.len(),
75 cap,
76 path.display()
77 ));
78 }
79 }
80
81 let mut file = std::fs::OpenOptions::new()
82 .read(true)
83 .open(path)
84 .map_err(|e| format!("ERROR: cannot open {}: {e}", path.display()))?;
85
86 use std::io::Read;
87 let mut raw: Vec<u8> = Vec::new();
88 let mut limited = (&mut file).take((cap as u64).saturating_add(1));
89 limited
90 .read_to_end(&mut raw)
91 .map_err(|e| format!("ERROR: cannot read {}: {e}", path.display()))?;
92 if raw.len() > cap {
93 return Err(format!(
94 "ERROR: file too large (cap {} via LCTX_MAX_READ_BYTES): {}",
95 cap,
96 path.display()
97 ));
98 }
99
100 let meta = file
101 .metadata()
102 .map_err(|e| format!("ERROR: cannot stat {}: {e}", path.display()))?;
103 Ok((raw, meta))
104}
105
106fn fingerprint_from_bytes(bytes: &[u8], meta: &std::fs::Metadata) -> FileFingerprint {
107 FileFingerprint {
108 size: bytes.len() as u64,
109 mtime_ms: meta.modified().map_or(0, system_time_to_millis),
110 md5: md5_hex_bytes(bytes),
111 }
112}
113
114fn read_preimage(path: &Path, cap: usize, allow_lossy_utf8: bool) -> Result<FilePreimage, String> {
115 let (bytes, meta) = read_file_bytes_limited(path, cap)?;
116 let permissions = meta.permissions();
117 let fp = fingerprint_from_bytes(&bytes, &meta);
118
119 let text = if allow_lossy_utf8 {
120 String::from_utf8_lossy(&bytes).into_owned()
121 } else {
122 String::from_utf8(bytes.clone()).map_err(|_| {
123 format!(
124 "ERROR: file is not valid UTF-8 (binary/encoding). Refusing to edit: {}",
125 path.display()
126 )
127 })?
128 };
129 let uses_crlf = text.contains("\r\n");
130
131 Ok(FilePreimage {
132 fp,
133 permissions,
134 bytes,
135 text,
136 uses_crlf,
137 })
138}
139
140fn verify_expected_preimage(pre: &FilePreimage, params: &EditParams) -> Result<(), String> {
141 if let Some(expected) = params.expected_size {
142 if expected != pre.fp.size {
143 return Err(format!(
144 "ERROR: preimage mismatch for {}: expected_size={}, actual_size={}",
145 params.path, expected, pre.fp.size
146 ));
147 }
148 }
149 if let Some(expected) = params.expected_mtime_ms {
150 if expected != pre.fp.mtime_ms {
151 return Err(format!(
152 "ERROR: preimage mismatch for {}: expected_mtime_ms={}, actual_mtime_ms={}",
153 params.path, expected, pre.fp.mtime_ms
154 ));
155 }
156 }
157 if let Some(expected) = params.expected_md5.as_deref() {
158 if expected != pre.fp.md5 {
159 return Err(format!(
160 "ERROR: preimage mismatch for {}: expected_md5={}, actual_md5={}",
161 params.path, expected, pre.fp.md5
162 ));
163 }
164 }
165 Ok(())
166}
167
168fn ensure_preimage_still_matches(
169 path: &Path,
170 expected: &FileFingerprint,
171 cap: usize,
172) -> Result<(), String> {
173 let (bytes, meta) = read_file_bytes_limited(path, cap)?;
174 let now = fingerprint_from_bytes(&bytes, &meta);
175 if &now != expected {
176 return Err(format!(
177 "ERROR: file changed since read (TOCTOU guard). Re-read and retry: {}\nexpected: size={}, mtime_ms={}, md5={}\nactual: size={}, mtime_ms={}, md5={}",
178 path.display(),
179 expected.size,
180 expected.mtime_ms,
181 expected.md5,
182 now.size,
183 now.mtime_ms,
184 now.md5
185 ));
186 }
187 Ok(())
188}
189
190fn default_backup_path(path: &Path) -> Option<PathBuf> {
191 let parent = path.parent()?;
192 let filename = path.file_name()?.to_string_lossy();
193 let pid = std::process::id();
194 let nanos = SystemTime::now()
195 .duration_since(UNIX_EPOCH)
196 .map_or(0, |d| d.as_nanos());
197 Some(parent.join(format!("{filename}.lean-ctx.bak.{pid}.{nanos}")))
198}
199
200fn write_atomic_bytes_with_permissions(
201 path: &Path,
202 bytes: &[u8],
203 permissions: Option<&std::fs::Permissions>,
204) -> Result<(), String> {
205 if let Some(parent) = path.parent() {
206 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
207 }
208
209 let parent = path
210 .parent()
211 .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
212 let filename = path
213 .file_name()
214 .ok_or_else(|| "invalid path (no filename)".to_string())?
215 .to_string_lossy();
216
217 let pid = std::process::id();
218 let nanos = SystemTime::now()
219 .duration_since(UNIX_EPOCH)
220 .map_or(0, |d| d.as_nanos());
221 let tmp = parent.join(format!(".{filename}.lean-ctx.tmp.{pid}.{nanos}"));
222
223 {
224 use std::io::Write;
225 let mut f = std::fs::OpenOptions::new()
226 .write(true)
227 .create_new(true)
228 .open(&tmp)
229 .map_err(|e| format!("ERROR: cannot write {}: {e}", tmp.display()))?;
230 f.write_all(bytes)
231 .map_err(|e| format!("ERROR: cannot write {}: {e}", tmp.display()))?;
232 let _ = f.flush();
233 let _ = f.sync_all();
234 }
235
236 if let Some(perms) = permissions {
237 let _ = std::fs::set_permissions(&tmp, perms.clone());
238 }
239
240 #[cfg(windows)]
241 {
242 if path.exists() {
243 let _ = std::fs::remove_file(path);
244 }
245 }
246
247 std::fs::rename(&tmp, path).map_err(|e| {
248 format!(
249 "ERROR: atomic write failed: {} (tmp: {})",
250 e,
251 tmp.to_string_lossy()
252 )
253 })?;
254
255 Ok(())
256}
257
258macro_rules! static_regex {
259 ($pattern:expr) => {{
260 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
261 RE.get_or_init(|| {
262 regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
263 })
264 }};
265}
266
267fn redact_sensitive_diff(input: &str) -> String {
268 let patterns: Vec<(&str, ®ex::Regex)> = vec![
269 (
270 "Bearer token",
271 static_regex!(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}"),
272 ),
273 (
274 "Authorization header",
275 static_regex!(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+"),
276 ),
277 (
278 "API key param",
279 static_regex!(
280 r#"(?i)((?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|token|password|passwd|pwd|secret)\s*[=:]\s*)[^\s\r\n,;&"']+"#
281 ),
282 ),
283 ("AWS key", static_regex!(r"(AKIA[0-9A-Z]{12,})")),
284 (
285 "Private key block",
286 static_regex!(
287 r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?(-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----)"
288 ),
289 ),
290 (
291 "GitHub token",
292 static_regex!(r"(gh[pousr]_)[a-zA-Z0-9]{20,}"),
293 ),
294 (
295 "Generic long secret",
296 static_regex!(
297 r#"(?i)(?:key|token|secret|password|credential|auth)\s*[=:]\s*['"]?([a-zA-Z0-9+/=\-_]{32,})['"]?"#
298 ),
299 ),
300 ];
301
302 let mut out = input.to_string();
303 for (label, re) in &patterns {
304 out = re
305 .replace_all(&out, |caps: ®ex::Captures| {
306 if let Some(prefix) = caps.get(1) {
307 format!("{}[REDACTED:{}]", prefix.as_str(), label)
308 } else {
309 format!("[REDACTED:{label}]")
310 }
311 })
312 .to_string();
313 }
314 out
315}
316
317fn build_diff_evidence(old: &str, new: &str, label: &str, max_lines: usize) -> String {
318 let diff = similar::TextDiff::from_lines(old, new)
319 .unified_diff()
320 .context_radius(3)
321 .header(label, label)
322 .to_string();
323 let diff = redact_sensitive_diff(&diff);
324
325 let mut out = String::new();
326 for (i, line) in diff.lines().enumerate() {
327 if i >= max_lines {
328 out.push_str(&format!("\n... diff truncated (max_lines={max_lines})"));
329 break;
330 }
331 out.push_str(line);
332 out.push('\n');
333 }
334 out.trim_end_matches('\n').to_string()
335}
336
337pub fn handle(cache: &mut SessionCache, params: &EditParams) -> String {
339 let file_path = ¶ms.path;
340
341 if params.create {
342 return handle_create(cache, file_path, ¶ms.new_string, params);
343 }
344
345 let cap = crate::core::limits::max_read_bytes();
346 let path = Path::new(file_path);
347 let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
348 Ok(p) => p,
349 Err(e) => return e,
350 };
351 if let Err(e) = verify_expected_preimage(&pre, params) {
352 return e;
353 }
354 let content = &pre.text;
355
356 if params.old_string.is_empty() {
357 return "ERROR: old_string must not be empty (use create=true to create a new file)".into();
358 }
359
360 let uses_crlf = pre.uses_crlf;
361 let old_str = ¶ms.old_string;
362 let new_str = ¶ms.new_string;
363
364 let occurrences = content.matches(old_str).count();
365
366 if occurrences > 0 {
367 let args = ReplaceArgs {
368 content,
369 old_str,
370 new_str,
371 occurrences,
372 replace_all: params.replace_all,
373 old_tokens: count_tokens(¶ms.old_string),
374 new_tokens: count_tokens(¶ms.new_string),
375 };
376 return do_replace(cache, path, &pre, params, cap, &args);
377 }
378
379 if uses_crlf && !old_str.contains('\r') {
381 let old_crlf = old_str.replace('\n', "\r\n");
382 let occ = content.matches(&old_crlf).count();
383 if occ > 0 {
384 let new_crlf = new_str.replace('\n', "\r\n");
385 let args = ReplaceArgs {
386 content,
387 old_str: &old_crlf,
388 new_str: &new_crlf,
389 occurrences: occ,
390 replace_all: params.replace_all,
391 old_tokens: count_tokens(¶ms.old_string),
392 new_tokens: count_tokens(¶ms.new_string),
393 };
394 return do_replace(cache, path, &pre, params, cap, &args);
395 }
396 } else if !uses_crlf && old_str.contains("\r\n") {
397 let old_lf = old_str.replace("\r\n", "\n");
398 let occ = content.matches(&old_lf).count();
399 if occ > 0 {
400 let new_lf = new_str.replace("\r\n", "\n");
401 let args = ReplaceArgs {
402 content,
403 old_str: &old_lf,
404 new_str: &new_lf,
405 occurrences: occ,
406 replace_all: params.replace_all,
407 old_tokens: count_tokens(¶ms.old_string),
408 new_tokens: count_tokens(¶ms.new_string),
409 };
410 return do_replace(cache, path, &pre, params, cap, &args);
411 }
412 }
413
414 let normalized_content = trim_trailing_per_line(content);
416 let normalized_old = trim_trailing_per_line(old_str);
417 if !normalized_old.is_empty() && normalized_content.contains(&normalized_old) {
418 let line_sep = if uses_crlf { "\r\n" } else { "\n" };
419 let adapted_new = adapt_new_string_to_line_sep(new_str, line_sep);
420 let adapted_old = find_original_span(content, &normalized_old);
421 if let Some(original_match) = adapted_old {
422 let occ = content.matches(&original_match).count();
423 let args = ReplaceArgs {
424 content,
425 old_str: &original_match,
426 new_str: &adapted_new,
427 occurrences: occ,
428 replace_all: params.replace_all,
429 old_tokens: count_tokens(¶ms.old_string),
430 new_tokens: count_tokens(¶ms.new_string),
431 };
432 return do_replace(cache, path, &pre, params, cap, &args);
433 }
434 }
435
436 let preview = if old_str.len() > 80 {
437 format!("{}...", &old_str[..77])
438 } else {
439 old_str.clone()
440 };
441 let hint = if uses_crlf {
442 " (file uses CRLF line endings)"
443 } else {
444 ""
445 };
446 format!(
447 "ERROR: old_string not found in {file_path}{hint}. \
448 Make sure it matches exactly (including whitespace/indentation).\n\
449 Searched for: {preview}"
450 )
451}
452
453fn do_replace(
454 cache: &mut SessionCache,
455 path: &Path,
456 pre: &FilePreimage,
457 params: &EditParams,
458 cap: usize,
459 args: &ReplaceArgs<'_>,
460) -> String {
461 if args.occurrences > 1 && !args.replace_all {
462 return format!(
463 "ERROR: old_string found {} times in {}. \
464 Use replace_all=true to replace all, or provide more context to make old_string unique."
465 ,
466 args.occurrences,
467 path.display()
468 );
469 }
470
471 let new_content = if args.replace_all {
472 args.content.replace(args.old_str, args.new_str)
473 } else {
474 args.content.replacen(args.old_str, args.new_str, 1)
475 };
476
477 if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
478 return e;
479 }
480
481 let backup_path = if params.backup {
482 let bp = params
483 .backup_path
484 .as_deref()
485 .map(PathBuf::from)
486 .or_else(|| default_backup_path(path));
487 let Some(bp) = bp else {
488 return format!("ERROR: cannot compute backup path for {}", path.display());
489 };
490 if let Err(e) = write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
491 {
492 return format!("ERROR: cannot create backup {}: {e}", bp.display());
493 }
494 Some(bp.to_string_lossy().to_string())
495 } else {
496 None
497 };
498
499 if let Err(e) =
500 write_atomic_bytes_with_permissions(path, new_content.as_bytes(), Some(&pre.permissions))
501 {
502 return e;
503 }
504
505 cache.invalidate(¶ms.path);
506
507 let old_lines = args.content.lines().count();
508 let new_lines = new_content.lines().count();
509 let line_delta = new_lines as i64 - old_lines as i64;
510 let delta_str = if line_delta > 0 {
511 format!("+{line_delta}")
512 } else {
513 format!("{line_delta}")
514 };
515
516 let old_tokens = args.old_tokens;
517 let new_tokens = args.new_tokens;
518
519 let replaced_str = if args.replace_all && args.occurrences > 1 {
520 format!("{} replacements", args.occurrences)
521 } else {
522 "1 replacement".into()
523 };
524
525 let short = path.file_name().map_or_else(
526 || path.to_string_lossy().to_string(),
527 |f| f.to_string_lossy().to_string(),
528 );
529
530 let post_mtime_ms = std::fs::metadata(path)
531 .ok()
532 .and_then(|m| m.modified().ok())
533 .map_or(0, system_time_to_millis);
534 let post_fp = FileFingerprint {
535 size: new_content.len() as u64,
536 mtime_ms: post_mtime_ms,
537 md5: md5_hex_bytes(new_content.as_bytes()),
538 };
539
540 let mut out = format!(
541 "✓ {short}: {replaced_str}, {delta_str} lines ({old_tokens}→{new_tokens} tok)\n\
542preimage: bytes={}, mtime_ms={}, md5={}\n\
543postimage: bytes={}, mtime_ms={}, md5={}",
544 pre.fp.size, pre.fp.mtime_ms, pre.fp.md5, post_fp.size, post_fp.mtime_ms, post_fp.md5
545 );
546 if let Some(bp) = backup_path {
547 out.push_str(&format!("\nbackup: {bp}"));
548 }
549 if params.evidence {
550 let diff = build_diff_evidence(args.content, &new_content, &short, params.diff_max_lines);
551 out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
552 out.push_str(&diff);
553 out.push_str("\n```");
554 }
555 out
556}
557
558fn handle_create(
559 cache: &mut SessionCache,
560 file_path: &str,
561 content: &str,
562 params: &EditParams,
563) -> String {
564 let path = Path::new(file_path);
565 let cap = crate::core::limits::max_read_bytes();
566
567 let mut preimage: Option<FilePreimage> = None;
568 if path.exists() {
569 let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
570 Ok(p) => p,
571 Err(e) => return e,
572 };
573 if let Err(e) = verify_expected_preimage(&pre, params) {
574 return e;
575 }
576 if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
577 return e;
578 }
579 preimage = Some(pre);
580 }
581
582 if let Some(parent) = path.parent() {
583 if !parent.exists() {
584 if let Err(e) = std::fs::create_dir_all(parent) {
585 return format!("ERROR: cannot create directory {}: {e}", parent.display());
586 }
587 }
588 }
589
590 let backup_path = if params.backup {
591 if let Some(pre) = &preimage {
592 let bp = params
593 .backup_path
594 .as_deref()
595 .map(PathBuf::from)
596 .or_else(|| default_backup_path(path));
597 let Some(bp) = bp else {
598 return format!("ERROR: cannot compute backup path for {}", path.display());
599 };
600 if let Err(e) =
601 write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
602 {
603 return format!("ERROR: cannot create backup {}: {e}", bp.display());
604 }
605 Some(bp.to_string_lossy().to_string())
606 } else {
607 None
608 }
609 } else {
610 None
611 };
612
613 let perms = preimage.as_ref().map(|p| &p.permissions);
614 if let Err(e) = write_atomic_bytes_with_permissions(path, content.as_bytes(), perms) {
615 return e;
616 }
617
618 cache.invalidate(file_path);
619
620 let lines = content.lines().count();
621 let tokens = count_tokens(content);
622 let short = path.file_name().map_or_else(
623 || path.to_string_lossy().to_string(),
624 |f| f.to_string_lossy().to_string(),
625 );
626
627 let mut out = format!("✓ created {short}: {lines} lines, {tokens} tok");
628 if let Some(bp) = backup_path {
629 out.push_str(&format!("\nbackup: {bp}"));
630 }
631 out
632}
633
634fn trim_trailing_per_line(s: &str) -> String {
635 s.lines().map(str::trim_end).collect::<Vec<_>>().join("\n")
636}
637
638fn adapt_new_string_to_line_sep(s: &str, sep: &str) -> String {
639 let normalized = s.replace("\r\n", "\n");
640 if sep == "\r\n" {
641 normalized.replace('\n', "\r\n")
642 } else {
643 normalized
644 }
645}
646
647fn find_original_span(content: &str, normalized_needle: &str) -> Option<String> {
650 let needle_lines: Vec<&str> = normalized_needle.lines().collect();
651 if needle_lines.is_empty() {
652 return None;
653 }
654
655 let content_lines: Vec<&str> = content.lines().collect();
656
657 'outer: for start in 0..content_lines.len() {
658 if start + needle_lines.len() > content_lines.len() {
659 break;
660 }
661 for (i, nl) in needle_lines.iter().enumerate() {
662 if content_lines[start + i].trim_end() != *nl {
663 continue 'outer;
664 }
665 }
666 let sep = if content.contains("\r\n") {
667 "\r\n"
668 } else {
669 "\n"
670 };
671 return Some(content_lines[start..start + needle_lines.len()].join(sep));
672 }
673 None
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679 use std::io::Write;
680 use tempfile::NamedTempFile;
681
682 fn make_temp(content: &str) -> NamedTempFile {
683 let mut f = NamedTempFile::new().unwrap();
684 f.write_all(content.as_bytes()).unwrap();
685 f
686 }
687
688 fn mk_params(path: &Path, old: &str, new: &str, replace_all: bool, create: bool) -> EditParams {
689 EditParams {
690 path: path.to_string_lossy().to_string(),
691 old_string: old.to_string(),
692 new_string: new.to_string(),
693 replace_all,
694 create,
695 expected_md5: None,
696 expected_size: None,
697 expected_mtime_ms: None,
698 backup: false,
699 backup_path: None,
700 evidence: false,
701 diff_max_lines: 200,
702 allow_lossy_utf8: false,
703 }
704 }
705
706 #[test]
707 fn replace_single_occurrence() {
708 let f = make_temp("fn hello() {\n println!(\"hello\");\n}\n");
709 let mut cache = SessionCache::new();
710 let result = handle(
711 &mut cache,
712 &mk_params(f.path(), "hello", "world", false, false),
713 );
714 assert!(result.contains("ERROR"), "should fail: 'hello' appears 2x");
715 }
716
717 #[test]
718 fn replace_all() {
719 let f = make_temp("aaa bbb aaa\n");
720 let mut cache = SessionCache::new();
721 let result = handle(&mut cache, &mk_params(f.path(), "aaa", "ccc", true, false));
722 assert!(result.contains("2 replacements"));
723 let content = std::fs::read_to_string(f.path()).unwrap();
724 assert_eq!(content, "ccc bbb ccc\n");
725 }
726
727 #[test]
728 fn not_found_error() {
729 let f = make_temp("some content\n");
730 let mut cache = SessionCache::new();
731 let result = handle(
732 &mut cache,
733 &mk_params(f.path(), "nonexistent", "x", false, false),
734 );
735 assert!(result.contains("ERROR: old_string not found"));
736 }
737
738 #[test]
739 fn create_new_file() {
740 let dir = tempfile::tempdir().unwrap();
741 let path = dir.path().join("sub/new_file.txt");
742 let mut cache = SessionCache::new();
743 let result = handle(
744 &mut cache,
745 &mk_params(&path, "", "line1\nline2\nline3\n", false, true),
746 );
747 assert!(result.contains("created new_file.txt"));
748 assert!(result.contains("3 lines"));
749 assert!(path.exists());
750 }
751
752 #[test]
753 fn unique_match_succeeds() {
754 let f = make_temp("fn main() {\n let x = 42;\n}\n");
755 let mut cache = SessionCache::new();
756 let result = handle(
757 &mut cache,
758 &mk_params(f.path(), "let x = 42", "let x = 99", false, false),
759 );
760 assert!(result.contains("✓"));
761 assert!(result.contains("1 replacement"));
762 let content = std::fs::read_to_string(f.path()).unwrap();
763 assert!(content.contains("let x = 99"));
764 }
765
766 #[test]
767 fn crlf_file_with_lf_search() {
768 let f = make_temp("line1\r\nline2\r\nline3\r\n");
769 let mut cache = SessionCache::new();
770 let result = handle(
771 &mut cache,
772 &mk_params(f.path(), "line1\nline2", "changed1\nchanged2", false, false),
773 );
774 assert!(result.contains("✓"), "CRLF fallback should work: {result}");
775 let content = std::fs::read_to_string(f.path()).unwrap();
776 assert!(
777 content.contains("changed1\r\nchanged2"),
778 "new_string should be adapted to CRLF: {content:?}"
779 );
780 assert!(
781 content.contains("\r\nline3\r\n"),
782 "rest of file should keep CRLF: {content:?}"
783 );
784 }
785
786 #[test]
787 fn lf_file_with_crlf_search() {
788 let f = make_temp("line1\nline2\nline3\n");
789 let mut cache = SessionCache::new();
790 let result = handle(
791 &mut cache,
792 &mk_params(f.path(), "line1\r\nline2", "a\r\nb", false, false),
793 );
794 assert!(result.contains("✓"), "LF fallback should work: {result}");
795 let content = std::fs::read_to_string(f.path()).unwrap();
796 assert!(
797 content.contains("a\nb"),
798 "new_string should be adapted to LF: {content:?}"
799 );
800 }
801
802 #[test]
803 fn trailing_whitespace_tolerance() {
804 let f = make_temp(" let x = 1; \n let y = 2;\n");
805 let mut cache = SessionCache::new();
806 let result = handle(
807 &mut cache,
808 &mk_params(
809 f.path(),
810 " let x = 1;\n let y = 2;",
811 " let x = 10;\n let y = 20;",
812 false,
813 false,
814 ),
815 );
816 assert!(
817 result.contains("✓"),
818 "trailing whitespace tolerance should work: {result}"
819 );
820 let content = std::fs::read_to_string(f.path()).unwrap();
821 assert!(content.contains("let x = 10;"));
822 assert!(content.contains("let y = 20;"));
823 }
824
825 #[test]
826 fn crlf_with_trailing_whitespace() {
827 let f = make_temp(" const a = 1; \r\n const b = 2;\r\n");
828 let mut cache = SessionCache::new();
829 let result = handle(
830 &mut cache,
831 &mk_params(
832 f.path(),
833 " const a = 1;\n const b = 2;",
834 " const a = 10;\n const b = 20;",
835 false,
836 false,
837 ),
838 );
839 assert!(
840 result.contains("✓"),
841 "CRLF + trailing whitespace should work: {result}"
842 );
843 let content = std::fs::read_to_string(f.path()).unwrap();
844 assert!(content.contains("const a = 10;"));
845 assert!(content.contains("const b = 20;"));
846 }
847
848 #[test]
849 fn rejects_invalid_utf8_by_default() {
850 let mut f = NamedTempFile::new().unwrap();
851 f.write_all(&[0xff, 0xfe, 0xfd]).unwrap();
852 let mut cache = SessionCache::new();
853 let result = handle(&mut cache, &mk_params(f.path(), "a", "b", false, false));
854 assert!(
855 result.contains("not valid UTF-8"),
856 "expected utf8 rejection, got: {result}"
857 );
858 }
859
860 #[test]
861 fn allows_lossy_utf8_only_when_enabled() {
862 let mut f = NamedTempFile::new().unwrap();
863 f.write_all(&[0xff, 0xfe, 0xfd]).unwrap();
864 let mut cache = SessionCache::new();
865 let mut p = mk_params(f.path(), "a", "b", false, false);
866 p.allow_lossy_utf8 = true;
867 let result = handle(&mut cache, &p);
868 assert!(
869 !result.contains("not valid UTF-8"),
870 "lossy mode should avoid utf8 hard error, got: {result}"
871 );
872 }
873
874 #[test]
875 fn expected_md5_mismatch_fails_without_writing() {
876 let f = make_temp("aaa\n");
877 let mut cache = SessionCache::new();
878 let mut p = mk_params(f.path(), "aaa", "bbb", false, false);
879 p.expected_md5 = Some("deadbeef".to_string());
880 let result = handle(&mut cache, &p);
881 assert!(
882 result.contains("preimage mismatch"),
883 "expected preimage mismatch, got: {result}"
884 );
885 let content = std::fs::read_to_string(f.path()).unwrap();
886 assert_eq!(content, "aaa\n");
887 }
888
889 #[test]
890 fn backup_is_created_when_enabled() {
891 let f = make_temp("aaa\n");
892 let mut cache = SessionCache::new();
893 let mut p = mk_params(f.path(), "aaa", "bbb", false, false);
894 p.backup = true;
895 let out = handle(&mut cache, &p);
896 assert!(out.contains("backup:"), "expected backup path, got: {out}");
897 let bp = out
898 .lines()
899 .find_map(|l| l.strip_prefix("backup: "))
900 .expect("backup line");
901 let backup_content = std::fs::read_to_string(bp).unwrap();
902 assert_eq!(backup_content, "aaa\n");
903 let content = std::fs::read_to_string(f.path()).unwrap();
904 assert_eq!(content, "bbb\n");
905 }
906
907 #[test]
908 fn evidence_diff_is_emitted_when_enabled() {
909 let f = make_temp("line1\nline2\n");
910 let mut cache = SessionCache::new();
911 let mut p = mk_params(f.path(), "line2", "changed2", false, false);
912 p.evidence = true;
913 p.diff_max_lines = 50;
914 let out = handle(&mut cache, &p);
915 assert!(out.contains("```diff"), "expected diff fence, got: {out}");
916 assert!(
917 out.contains("preimage:"),
918 "expected preimage metadata, got: {out}"
919 );
920 assert!(
921 out.contains("postimage:"),
922 "expected postimage metadata, got: {out}"
923 );
924 }
925
926 #[test]
927 fn detects_toctou_via_preimage_guard() {
928 let f = make_temp("aaa\n");
929 let cap = crate::core::limits::max_read_bytes();
930 let pre = read_preimage(f.path(), cap, false).unwrap();
931 std::fs::write(f.path(), "bbb\n").unwrap();
932 let err = ensure_preimage_still_matches(f.path(), &pre.fp, cap).unwrap_err();
933 assert!(err.contains("TOCTOU guard"), "unexpected error: {err}");
934 }
935}