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 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, &regex::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: &regex::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
337/// Performs a string replacement edit on a file with CRLF/LF and whitespace tolerance.
338pub fn handle(cache: &mut SessionCache, params: &EditParams) -> String {
339    let file_path = &params.path;
340
341    if params.create {
342        return handle_create(cache, file_path, &params.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 = &params.old_string;
362    let new_str = &params.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(&params.old_string),
374            new_tokens: count_tokens(&params.new_string),
375        };
376        return do_replace(cache, path, &pre, params, cap, &args);
377    }
378
379    // Direct match failed -- try CRLF/LF normalization
380    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(&params.old_string),
392                new_tokens: count_tokens(&params.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(&params.old_string),
408                new_tokens: count_tokens(&params.new_string),
409            };
410            return do_replace(cache, path, &pre, params, cap, &args);
411        }
412    }
413
414    // Still not found -- try trimmed trailing whitespace per line
415    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(&params.old_string),
430                new_tokens: count_tokens(&params.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(&params.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
647/// Find the original (un-trimmed) span in `content` that matches `normalized_needle`
648/// after trailing-whitespace trimming per line.
649fn 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}