Skip to main content

lean_ctx/tools/
ctx_edit.rs

1use std::path::{Path, PathBuf};
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use crate::core::cache::SessionCache;
5use crate::core::tokens::count_tokens;
6
7/// Parameters for a file edit operation: path, old/new strings, and flags.
8pub 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    /// Optional preimage guards. If provided, ctx_edit fails if the current file preimage differs.
15    pub expected_md5: Option<String>,
16    pub expected_size: Option<u64>,
17    pub expected_mtime_ms: Option<u64>,
18    /// Optional backup before writing.
19    pub backup: bool,
20    pub backup_path: Option<String>,
21    /// Emit bounded diff evidence (redacted) by default.
22    pub evidence: bool,
23    pub diff_max_lines: usize,
24    /// Reject invalid UTF-8 by default; allow lossy reads only when explicitly enabled.
25    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, &regex::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: &regex::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
330/// Performs a string replacement edit on a file with CRLF/LF and whitespace tolerance.
331pub fn handle(cache: &mut SessionCache, params: &EditParams) -> String {
332    let file_path = &params.path;
333
334    if params.create {
335        return handle_create(cache, file_path, &params.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 = &params.old_string;
355    let new_str = &params.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(&params.old_string),
367            new_tokens: count_tokens(&params.new_string),
368        };
369        return do_replace(cache, path, &pre, params, cap, &args);
370    }
371
372    // Direct match failed -- try CRLF/LF normalization
373    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(&params.old_string),
385                new_tokens: count_tokens(&params.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(&params.old_string),
401                new_tokens: count_tokens(&params.new_string),
402            };
403            return do_replace(cache, path, &pre, params, cap, &args);
404        }
405    }
406
407    // Still not found -- try trimmed trailing whitespace per line
408    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(&params.old_string),
423                new_tokens: count_tokens(&params.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
449/// Auto-escalation: when old_string is not found and the file was previously read
450/// in a compressed mode, re-read in full and return the content so the agent
451/// can immediately retry with the correct old_string.
452fn 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(&params.path);
542
543    if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
544        bt.record_edit(&params.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
687/// Find the original (un-trimmed) span in `content` that matches `normalized_needle`
688/// after trailing-whitespace trimming per line.
689fn 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}