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
6pub struct EditParams {
7    pub path: String,
8    pub old_string: String,
9    pub new_string: String,
10    pub replace_all: bool,
11    pub create: bool,
12}
13
14pub fn handle(cache: &mut SessionCache, params: EditParams) -> String {
15    let file_path = &params.path;
16
17    if params.create {
18        return handle_create(cache, file_path, &params.new_string);
19    }
20
21    let content = match std::fs::read_to_string(file_path) {
22        Ok(c) => c,
23        Err(e) => return format!("ERROR: cannot read {file_path}: {e}"),
24    };
25
26    if params.old_string.is_empty() {
27        return "ERROR: old_string must not be empty (use create=true to create a new file)".into();
28    }
29
30    let occurrences = content.matches(&params.old_string).count();
31
32    if occurrences == 0 {
33        let preview = if params.old_string.len() > 80 {
34            format!("{}...", &params.old_string[..77])
35        } else {
36            params.old_string.clone()
37        };
38        return format!(
39            "ERROR: old_string not found in {file_path}. \
40             Make sure it matches exactly (including whitespace/indentation).\n\
41             Searched for: {preview}"
42        );
43    }
44
45    if occurrences > 1 && !params.replace_all {
46        return format!(
47            "ERROR: old_string found {occurrences} times in {file_path}. \
48             Use replace_all=true to replace all, or provide more context to make old_string unique."
49        );
50    }
51
52    let new_content = if params.replace_all {
53        content.replace(&params.old_string, &params.new_string)
54    } else {
55        content.replacen(&params.old_string, &params.new_string, 1)
56    };
57
58    if let Err(e) = std::fs::write(file_path, &new_content) {
59        return format!("ERROR: cannot write {file_path}: {e}");
60    }
61
62    cache.invalidate(file_path);
63
64    let old_lines = content.lines().count();
65    let new_lines = new_content.lines().count();
66    let line_delta = new_lines as i64 - old_lines as i64;
67    let delta_str = if line_delta > 0 {
68        format!("+{line_delta}")
69    } else {
70        format!("{line_delta}")
71    };
72
73    let old_tokens = count_tokens(&params.old_string);
74    let new_tokens = count_tokens(&params.new_string);
75
76    let replaced_str = if params.replace_all && occurrences > 1 {
77        format!("{occurrences} replacements")
78    } else {
79        "1 replacement".into()
80    };
81
82    let short = Path::new(file_path)
83        .file_name()
84        .map(|f| f.to_string_lossy().to_string())
85        .unwrap_or_else(|| file_path.to_string());
86
87    format!("✓ {short}: {replaced_str}, {delta_str} lines ({old_tokens}→{new_tokens} tok)")
88}
89
90fn handle_create(cache: &mut SessionCache, file_path: &str, content: &str) -> String {
91    if let Some(parent) = Path::new(file_path).parent() {
92        if !parent.exists() {
93            if let Err(e) = std::fs::create_dir_all(parent) {
94                return format!("ERROR: cannot create directory {}: {e}", parent.display());
95            }
96        }
97    }
98
99    if let Err(e) = std::fs::write(file_path, content) {
100        return format!("ERROR: cannot write {file_path}: {e}");
101    }
102
103    cache.invalidate(file_path);
104
105    let lines = content.lines().count();
106    let tokens = count_tokens(content);
107    let short = Path::new(file_path)
108        .file_name()
109        .map(|f| f.to_string_lossy().to_string())
110        .unwrap_or_else(|| file_path.to_string());
111
112    format!("✓ created {short}: {lines} lines, {tokens} tok")
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use std::io::Write;
119    use tempfile::NamedTempFile;
120
121    fn make_temp(content: &str) -> NamedTempFile {
122        let mut f = NamedTempFile::new().unwrap();
123        f.write_all(content.as_bytes()).unwrap();
124        f
125    }
126
127    #[test]
128    fn replace_single_occurrence() {
129        let f = make_temp("fn hello() {\n    println!(\"hello\");\n}\n");
130        let mut cache = SessionCache::new();
131        let result = handle(
132            &mut cache,
133            EditParams {
134                path: f.path().to_str().unwrap().to_string(),
135                old_string: "hello".into(),
136                new_string: "world".into(),
137                replace_all: false,
138                create: false,
139            },
140        );
141        assert!(result.contains("ERROR"), "should fail: 'hello' appears 2x");
142    }
143
144    #[test]
145    fn replace_all() {
146        let f = make_temp("aaa bbb aaa\n");
147        let mut cache = SessionCache::new();
148        let result = handle(
149            &mut cache,
150            EditParams {
151                path: f.path().to_str().unwrap().to_string(),
152                old_string: "aaa".into(),
153                new_string: "ccc".into(),
154                replace_all: true,
155                create: false,
156            },
157        );
158        assert!(result.contains("2 replacements"));
159        let content = std::fs::read_to_string(f.path()).unwrap();
160        assert_eq!(content, "ccc bbb ccc\n");
161    }
162
163    #[test]
164    fn not_found_error() {
165        let f = make_temp("some content\n");
166        let mut cache = SessionCache::new();
167        let result = handle(
168            &mut cache,
169            EditParams {
170                path: f.path().to_str().unwrap().to_string(),
171                old_string: "nonexistent".into(),
172                new_string: "x".into(),
173                replace_all: false,
174                create: false,
175            },
176        );
177        assert!(result.contains("ERROR: old_string not found"));
178    }
179
180    #[test]
181    fn create_new_file() {
182        let dir = tempfile::tempdir().unwrap();
183        let path = dir.path().join("sub/new_file.txt");
184        let mut cache = SessionCache::new();
185        let result = handle(
186            &mut cache,
187            EditParams {
188                path: path.to_str().unwrap().to_string(),
189                old_string: String::new(),
190                new_string: "line1\nline2\nline3\n".into(),
191                replace_all: false,
192                create: true,
193            },
194        );
195        assert!(result.contains("created new_file.txt"));
196        assert!(result.contains("3 lines"));
197        assert!(path.exists());
198    }
199
200    #[test]
201    fn unique_match_succeeds() {
202        let f = make_temp("fn main() {\n    let x = 42;\n}\n");
203        let mut cache = SessionCache::new();
204        let result = handle(
205            &mut cache,
206            EditParams {
207                path: f.path().to_str().unwrap().to_string(),
208                old_string: "let x = 42".into(),
209                new_string: "let x = 99".into(),
210                replace_all: false,
211                create: false,
212            },
213        );
214        assert!(result.contains("✓"));
215        assert!(result.contains("1 replacement"));
216        let content = std::fs::read_to_string(f.path()).unwrap();
217        assert!(content.contains("let x = 99"));
218    }
219}