lean_ctx/tools/
ctx_edit.rs1use 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 = ¶ms.path;
16
17 if params.create {
18 return handle_create(cache, file_path, ¶ms.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(¶ms.old_string).count();
31
32 if occurrences == 0 {
33 let preview = if params.old_string.len() > 80 {
34 format!("{}...", ¶ms.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(¶ms.old_string, ¶ms.new_string)
54 } else {
55 content.replacen(¶ms.old_string, ¶ms.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(¶ms.old_string);
74 let new_tokens = count_tokens(¶ms.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}