Skip to main content

lean_ctx/tools/
ctx_edit.rs

1use std::path::Path;
2
3use crate::core::cache::SessionCache;
4use crate::core::tokens::count_tokens;
5
6/// Parameters for a file edit operation: path, old/new strings, and flags.
7pub struct EditParams {
8    pub path: String,
9    pub old_string: String,
10    pub new_string: String,
11    pub replace_all: bool,
12    pub create: bool,
13}
14
15struct ReplaceArgs<'a> {
16    content: &'a str,
17    old_str: &'a str,
18    new_str: &'a str,
19    occurrences: usize,
20    replace_all: bool,
21    old_tokens: usize,
22    new_tokens: usize,
23}
24
25/// Performs a string replacement edit on a file with CRLF/LF and whitespace tolerance.
26pub fn handle(cache: &mut SessionCache, params: &EditParams) -> String {
27    let file_path = &params.path;
28
29    if params.create {
30        return handle_create(cache, file_path, &params.new_string);
31    }
32
33    let cap = crate::core::limits::max_read_bytes();
34    if let Ok(meta) = std::fs::metadata(file_path) {
35        if meta.len() > cap as u64 {
36            return format!(
37                "ERROR: file too large ({} bytes, cap {} via LCTX_MAX_READ_BYTES): {file_path}",
38                meta.len(),
39                cap
40            );
41        }
42    }
43
44    let mut file = match std::fs::OpenOptions::new()
45        .read(true)
46        .write(true)
47        .open(file_path)
48    {
49        Ok(f) => f,
50        Err(e) => return format!("ERROR: cannot open {file_path}: {e}"),
51    };
52
53    let mut raw_bytes: Vec<u8> = Vec::new();
54    {
55        use std::io::Read;
56        let mut limited = (&mut file).take((cap as u64).saturating_add(1));
57        if let Err(e) = limited.read_to_end(&mut raw_bytes) {
58            return format!("ERROR: cannot read {file_path}: {e}");
59        }
60    }
61    if raw_bytes.len() > cap {
62        return format!("ERROR: file too large (cap {cap} via LCTX_MAX_READ_BYTES): {file_path}");
63    }
64
65    let content = String::from_utf8_lossy(&raw_bytes).into_owned();
66
67    if params.old_string.is_empty() {
68        return "ERROR: old_string must not be empty (use create=true to create a new file)".into();
69    }
70
71    let uses_crlf = content.contains("\r\n");
72    let old_str = &params.old_string;
73    let new_str = &params.new_string;
74
75    let occurrences = content.matches(old_str).count();
76
77    if occurrences > 0 {
78        let args = ReplaceArgs {
79            content: &content,
80            old_str,
81            new_str,
82            occurrences,
83            replace_all: params.replace_all,
84            old_tokens: count_tokens(&params.old_string),
85            new_tokens: count_tokens(&params.new_string),
86        };
87        return do_replace(cache, &mut file, file_path, &args);
88    }
89
90    // Direct match failed -- try CRLF/LF normalization
91    if uses_crlf && !old_str.contains('\r') {
92        let old_crlf = old_str.replace('\n', "\r\n");
93        let occ = content.matches(&old_crlf).count();
94        if occ > 0 {
95            let new_crlf = new_str.replace('\n', "\r\n");
96            let args = ReplaceArgs {
97                content: &content,
98                old_str: &old_crlf,
99                new_str: &new_crlf,
100                occurrences: occ,
101                replace_all: params.replace_all,
102                old_tokens: count_tokens(&params.old_string),
103                new_tokens: count_tokens(&params.new_string),
104            };
105            return do_replace(cache, &mut file, file_path, &args);
106        }
107    } else if !uses_crlf && old_str.contains("\r\n") {
108        let old_lf = old_str.replace("\r\n", "\n");
109        let occ = content.matches(&old_lf).count();
110        if occ > 0 {
111            let new_lf = new_str.replace("\r\n", "\n");
112            let args = ReplaceArgs {
113                content: &content,
114                old_str: &old_lf,
115                new_str: &new_lf,
116                occurrences: occ,
117                replace_all: params.replace_all,
118                old_tokens: count_tokens(&params.old_string),
119                new_tokens: count_tokens(&params.new_string),
120            };
121            return do_replace(cache, &mut file, file_path, &args);
122        }
123    }
124
125    // Still not found -- try trimmed trailing whitespace per line
126    let normalized_content = trim_trailing_per_line(&content);
127    let normalized_old = trim_trailing_per_line(old_str);
128    if !normalized_old.is_empty() && normalized_content.contains(&normalized_old) {
129        let line_sep = if uses_crlf { "\r\n" } else { "\n" };
130        let adapted_new = adapt_new_string_to_line_sep(new_str, line_sep);
131        let adapted_old = find_original_span(&content, &normalized_old);
132        if let Some(original_match) = adapted_old {
133            let occ = content.matches(&original_match).count();
134            let args = ReplaceArgs {
135                content: &content,
136                old_str: &original_match,
137                new_str: &adapted_new,
138                occurrences: occ,
139                replace_all: params.replace_all,
140                old_tokens: count_tokens(&params.old_string),
141                new_tokens: count_tokens(&params.new_string),
142            };
143            return do_replace(cache, &mut file, file_path, &args);
144        }
145    }
146
147    let preview = if old_str.len() > 80 {
148        format!("{}...", &old_str[..77])
149    } else {
150        old_str.clone()
151    };
152    let hint = if uses_crlf {
153        " (file uses CRLF line endings)"
154    } else {
155        ""
156    };
157    format!(
158        "ERROR: old_string not found in {file_path}{hint}. \
159         Make sure it matches exactly (including whitespace/indentation).\n\
160         Searched for: {preview}"
161    )
162}
163
164fn do_replace(
165    cache: &mut SessionCache,
166    file: &mut std::fs::File,
167    file_path: &str,
168    args: &ReplaceArgs<'_>,
169) -> String {
170    if args.occurrences > 1 && !args.replace_all {
171        return format!(
172            "ERROR: old_string found {} times in {file_path}. \
173             Use replace_all=true to replace all, or provide more context to make old_string unique."
174            , args.occurrences
175        );
176    }
177
178    let new_content = if args.replace_all {
179        args.content.replace(args.old_str, args.new_str)
180    } else {
181        args.content.replacen(args.old_str, args.new_str, 1)
182    };
183
184    use std::io::{Seek, SeekFrom, Write};
185    if let Err(e) = file.set_len(0) {
186        return format!("ERROR: cannot write {file_path}: {e}");
187    }
188    if let Err(e) = file.seek(SeekFrom::Start(0)) {
189        return format!("ERROR: cannot write {file_path}: {e}");
190    }
191    if let Err(e) = file.write_all(new_content.as_bytes()) {
192        return format!("ERROR: cannot write {file_path}: {e}");
193    }
194    let _ = file.flush();
195    let _ = file.sync_all();
196
197    cache.invalidate(file_path);
198
199    let old_lines = args.content.lines().count();
200    let new_lines = new_content.lines().count();
201    let line_delta = new_lines as i64 - old_lines as i64;
202    let delta_str = if line_delta > 0 {
203        format!("+{line_delta}")
204    } else {
205        format!("{line_delta}")
206    };
207
208    let old_tokens = args.old_tokens;
209    let new_tokens = args.new_tokens;
210
211    let replaced_str = if args.replace_all && args.occurrences > 1 {
212        format!("{} replacements", args.occurrences)
213    } else {
214        "1 replacement".into()
215    };
216
217    let short = Path::new(file_path).file_name().map_or_else(
218        || file_path.to_string(),
219        |f| f.to_string_lossy().to_string(),
220    );
221
222    format!("✓ {short}: {replaced_str}, {delta_str} lines ({old_tokens}→{new_tokens} tok)")
223}
224
225fn handle_create(cache: &mut SessionCache, file_path: &str, content: &str) -> String {
226    if let Some(parent) = Path::new(file_path).parent() {
227        if !parent.exists() {
228            if let Err(e) = std::fs::create_dir_all(parent) {
229                return format!("ERROR: cannot create directory {}: {e}", parent.display());
230            }
231        }
232    }
233
234    if let Err(e) = std::fs::write(file_path, content) {
235        return format!("ERROR: cannot write {file_path}: {e}");
236    }
237
238    cache.invalidate(file_path);
239
240    let lines = content.lines().count();
241    let tokens = count_tokens(content);
242    let short = Path::new(file_path).file_name().map_or_else(
243        || file_path.to_string(),
244        |f| f.to_string_lossy().to_string(),
245    );
246
247    format!("✓ created {short}: {lines} lines, {tokens} tok")
248}
249
250fn trim_trailing_per_line(s: &str) -> String {
251    s.lines().map(str::trim_end).collect::<Vec<_>>().join("\n")
252}
253
254fn adapt_new_string_to_line_sep(s: &str, sep: &str) -> String {
255    let normalized = s.replace("\r\n", "\n");
256    if sep == "\r\n" {
257        normalized.replace('\n', "\r\n")
258    } else {
259        normalized
260    }
261}
262
263/// Find the original (un-trimmed) span in `content` that matches `normalized_needle`
264/// after trailing-whitespace trimming per line.
265fn find_original_span(content: &str, normalized_needle: &str) -> Option<String> {
266    let needle_lines: Vec<&str> = normalized_needle.lines().collect();
267    if needle_lines.is_empty() {
268        return None;
269    }
270
271    let content_lines: Vec<&str> = content.lines().collect();
272
273    'outer: for start in 0..content_lines.len() {
274        if start + needle_lines.len() > content_lines.len() {
275            break;
276        }
277        for (i, nl) in needle_lines.iter().enumerate() {
278            if content_lines[start + i].trim_end() != *nl {
279                continue 'outer;
280            }
281        }
282        let sep = if content.contains("\r\n") {
283            "\r\n"
284        } else {
285            "\n"
286        };
287        return Some(content_lines[start..start + needle_lines.len()].join(sep));
288    }
289    None
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use std::io::Write;
296    use tempfile::NamedTempFile;
297
298    fn make_temp(content: &str) -> NamedTempFile {
299        let mut f = NamedTempFile::new().unwrap();
300        f.write_all(content.as_bytes()).unwrap();
301        f
302    }
303
304    #[test]
305    fn replace_single_occurrence() {
306        let f = make_temp("fn hello() {\n    println!(\"hello\");\n}\n");
307        let mut cache = SessionCache::new();
308        let result = handle(
309            &mut cache,
310            &EditParams {
311                path: f.path().to_str().unwrap().to_string(),
312                old_string: "hello".into(),
313                new_string: "world".into(),
314                replace_all: false,
315                create: false,
316            },
317        );
318        assert!(result.contains("ERROR"), "should fail: 'hello' appears 2x");
319    }
320
321    #[test]
322    fn replace_all() {
323        let f = make_temp("aaa bbb aaa\n");
324        let mut cache = SessionCache::new();
325        let result = handle(
326            &mut cache,
327            &EditParams {
328                path: f.path().to_str().unwrap().to_string(),
329                old_string: "aaa".into(),
330                new_string: "ccc".into(),
331                replace_all: true,
332                create: false,
333            },
334        );
335        assert!(result.contains("2 replacements"));
336        let content = std::fs::read_to_string(f.path()).unwrap();
337        assert_eq!(content, "ccc bbb ccc\n");
338    }
339
340    #[test]
341    fn not_found_error() {
342        let f = make_temp("some content\n");
343        let mut cache = SessionCache::new();
344        let result = handle(
345            &mut cache,
346            &EditParams {
347                path: f.path().to_str().unwrap().to_string(),
348                old_string: "nonexistent".into(),
349                new_string: "x".into(),
350                replace_all: false,
351                create: false,
352            },
353        );
354        assert!(result.contains("ERROR: old_string not found"));
355    }
356
357    #[test]
358    fn create_new_file() {
359        let dir = tempfile::tempdir().unwrap();
360        let path = dir.path().join("sub/new_file.txt");
361        let mut cache = SessionCache::new();
362        let result = handle(
363            &mut cache,
364            &EditParams {
365                path: path.to_str().unwrap().to_string(),
366                old_string: String::new(),
367                new_string: "line1\nline2\nline3\n".into(),
368                replace_all: false,
369                create: true,
370            },
371        );
372        assert!(result.contains("created new_file.txt"));
373        assert!(result.contains("3 lines"));
374        assert!(path.exists());
375    }
376
377    #[test]
378    fn unique_match_succeeds() {
379        let f = make_temp("fn main() {\n    let x = 42;\n}\n");
380        let mut cache = SessionCache::new();
381        let result = handle(
382            &mut cache,
383            &EditParams {
384                path: f.path().to_str().unwrap().to_string(),
385                old_string: "let x = 42".into(),
386                new_string: "let x = 99".into(),
387                replace_all: false,
388                create: false,
389            },
390        );
391        assert!(result.contains("✓"));
392        assert!(result.contains("1 replacement"));
393        let content = std::fs::read_to_string(f.path()).unwrap();
394        assert!(content.contains("let x = 99"));
395    }
396
397    #[test]
398    fn crlf_file_with_lf_search() {
399        let f = make_temp("line1\r\nline2\r\nline3\r\n");
400        let mut cache = SessionCache::new();
401        let result = handle(
402            &mut cache,
403            &EditParams {
404                path: f.path().to_str().unwrap().to_string(),
405                old_string: "line1\nline2".into(),
406                new_string: "changed1\nchanged2".into(),
407                replace_all: false,
408                create: false,
409            },
410        );
411        assert!(result.contains("✓"), "CRLF fallback should work: {result}");
412        let content = std::fs::read_to_string(f.path()).unwrap();
413        assert!(
414            content.contains("changed1\r\nchanged2"),
415            "new_string should be adapted to CRLF: {content:?}"
416        );
417        assert!(
418            content.contains("\r\nline3\r\n"),
419            "rest of file should keep CRLF: {content:?}"
420        );
421    }
422
423    #[test]
424    fn lf_file_with_crlf_search() {
425        let f = make_temp("line1\nline2\nline3\n");
426        let mut cache = SessionCache::new();
427        let result = handle(
428            &mut cache,
429            &EditParams {
430                path: f.path().to_str().unwrap().to_string(),
431                old_string: "line1\r\nline2".into(),
432                new_string: "a\r\nb".into(),
433                replace_all: false,
434                create: false,
435            },
436        );
437        assert!(result.contains("✓"), "LF fallback should work: {result}");
438        let content = std::fs::read_to_string(f.path()).unwrap();
439        assert!(
440            content.contains("a\nb"),
441            "new_string should be adapted to LF: {content:?}"
442        );
443    }
444
445    #[test]
446    fn trailing_whitespace_tolerance() {
447        let f = make_temp("  let x = 1;  \n  let y = 2;\n");
448        let mut cache = SessionCache::new();
449        let result = handle(
450            &mut cache,
451            &EditParams {
452                path: f.path().to_str().unwrap().to_string(),
453                old_string: "  let x = 1;\n  let y = 2;".into(),
454                new_string: "  let x = 10;\n  let y = 20;".into(),
455                replace_all: false,
456                create: false,
457            },
458        );
459        assert!(
460            result.contains("✓"),
461            "trailing whitespace tolerance should work: {result}"
462        );
463        let content = std::fs::read_to_string(f.path()).unwrap();
464        assert!(content.contains("let x = 10;"));
465        assert!(content.contains("let y = 20;"));
466    }
467
468    #[test]
469    fn crlf_with_trailing_whitespace() {
470        let f = make_temp("  const a = 1;  \r\n  const b = 2;\r\n");
471        let mut cache = SessionCache::new();
472        let result = handle(
473            &mut cache,
474            &EditParams {
475                path: f.path().to_str().unwrap().to_string(),
476                old_string: "  const a = 1;\n  const b = 2;".into(),
477                new_string: "  const a = 10;\n  const b = 20;".into(),
478                replace_all: false,
479                create: false,
480            },
481        );
482        assert!(
483            result.contains("✓"),
484            "CRLF + trailing whitespace should work: {result}"
485        );
486        let content = std::fs::read_to_string(f.path()).unwrap();
487        assert!(content.contains("const a = 10;"));
488        assert!(content.contains("const b = 20;"));
489    }
490}