codelens_engine/file_ops/
writer.rs1use crate::edit_transaction::{ApplyEvidence, apply_full_write_with_evidence};
17use crate::project::ProjectRoot;
18use anyhow::{Context, Result, bail};
19use regex::Regex;
20use std::fs;
21
22pub fn create_text_file(
23 project: &ProjectRoot,
24 relative_path: &str,
25 content: &str,
26 overwrite: bool,
27) -> Result<ApplyEvidence> {
28 let resolved = project.resolve(relative_path)?;
29 if !overwrite && resolved.exists() {
30 bail!("file already exists: {}", resolved.display());
31 }
32 if let Some(parent) = resolved.parent() {
33 fs::create_dir_all(parent)
34 .with_context(|| format!("failed to create directories for {}", resolved.display()))?;
35 }
36 let evidence = match apply_full_write_with_evidence(project, relative_path, content) {
37 Ok(ev) => ev,
38 Err(crate::edit_transaction::ApplyError::ApplyFailed {
39 source: _,
40 evidence,
41 }) => {
42 evidence
46 }
47 Err(other) => return Err(anyhow::Error::msg(other.to_string())),
48 };
49 Ok(evidence)
50}
51
52pub fn delete_lines(
53 project: &ProjectRoot,
54 relative_path: &str,
55 start_line: usize,
56 end_line: usize,
57) -> Result<(String, ApplyEvidence)> {
58 let resolved = project.resolve(relative_path)?;
59 let content = fs::read_to_string(&resolved)
60 .with_context(|| format!("failed to read {}", resolved.display()))?;
61 let mut lines: Vec<&str> = content.lines().collect();
62 let total = lines.len();
63 if start_line < 1 || start_line > total + 1 {
64 bail!(
65 "start_line {} out of range (file has {} lines)",
66 start_line,
67 total
68 );
69 }
70 if end_line < start_line || end_line > total + 1 {
71 bail!("end_line {} out of range", end_line);
72 }
73 let from = start_line - 1;
75 let to = (end_line - 1).min(lines.len());
76 lines.drain(from..to);
77 let result = lines.join("\n");
78 let result = if content.ends_with('\n') {
80 format!("{result}\n")
81 } else {
82 result
83 };
84 let evidence = match apply_full_write_with_evidence(project, relative_path, &result) {
85 Ok(ev) => ev,
86 Err(crate::edit_transaction::ApplyError::ApplyFailed {
87 source: _,
88 evidence,
89 }) => {
90 evidence
94 }
95 Err(other) => return Err(anyhow::Error::msg(other.to_string())),
96 };
97 Ok((result, evidence))
98}
99
100pub fn insert_at_line(
101 project: &ProjectRoot,
102 relative_path: &str,
103 line: usize,
104 content_to_insert: &str,
105) -> Result<(String, ApplyEvidence)> {
106 let resolved = project.resolve(relative_path)?;
107 let content = fs::read_to_string(&resolved)
108 .with_context(|| format!("failed to read {}", resolved.display()))?;
109 let mut lines: Vec<&str> = content.lines().collect();
110 let total = lines.len();
111 if line < 1 || line > total + 1 {
112 bail!("line {} out of range (file has {} lines)", line, total);
113 }
114 let insert_pos = line - 1;
115 let new_lines: Vec<&str> = content_to_insert.lines().collect();
116 for (i, new_line) in new_lines.iter().enumerate() {
117 lines.insert(insert_pos + i, new_line);
118 }
119 let result = lines.join("\n");
120 let result = if content.ends_with('\n') || content_to_insert.ends_with('\n') {
121 format!("{result}\n")
122 } else {
123 result
124 };
125 let evidence = match apply_full_write_with_evidence(project, relative_path, &result) {
126 Ok(ev) => ev,
127 Err(crate::edit_transaction::ApplyError::ApplyFailed {
128 source: _,
129 evidence,
130 }) => {
131 evidence
135 }
136 Err(other) => return Err(anyhow::Error::msg(other.to_string())),
137 };
138 Ok((result, evidence))
139}
140
141pub fn replace_lines(
142 project: &ProjectRoot,
143 relative_path: &str,
144 start_line: usize,
145 end_line: usize,
146 new_content: &str,
147) -> Result<(String, ApplyEvidence)> {
148 let resolved = project.resolve(relative_path)?;
149 let content = fs::read_to_string(&resolved)
150 .with_context(|| format!("failed to read {}", resolved.display()))?;
151 let mut lines: Vec<&str> = content.lines().collect();
152 let total = lines.len();
153 if start_line < 1 || start_line > total + 1 {
154 bail!(
155 "start_line {} out of range (file has {} lines)",
156 start_line,
157 total
158 );
159 }
160 if end_line < start_line || end_line > total + 1 {
161 bail!("end_line {} out of range", end_line);
162 }
163 let from = start_line - 1;
164 let to = (end_line - 1).min(lines.len());
165 lines.drain(from..to);
166 let replacement: Vec<&str> = new_content.lines().collect();
167 for (i, rep_line) in replacement.iter().enumerate() {
168 lines.insert(from + i, rep_line);
169 }
170 let result = lines.join("\n");
171 let result = if content.ends_with('\n') {
172 format!("{result}\n")
173 } else {
174 result
175 };
176 let evidence = match apply_full_write_with_evidence(project, relative_path, &result) {
177 Ok(ev) => ev,
178 Err(crate::edit_transaction::ApplyError::ApplyFailed {
179 source: _,
180 evidence,
181 }) => {
182 evidence
186 }
187 Err(other) => return Err(anyhow::Error::msg(other.to_string())),
188 };
189 Ok((result, evidence))
190}
191
192pub fn replace_content(
193 project: &ProjectRoot,
194 relative_path: &str,
195 old_text: &str,
196 new_text: &str,
197 regex_mode: bool,
198) -> Result<(String, usize, ApplyEvidence)> {
199 let resolved = project.resolve(relative_path)?;
200 let content = fs::read_to_string(&resolved)
201 .with_context(|| format!("failed to read {}", resolved.display()))?;
202 let (result, count) = if regex_mode {
203 let re = Regex::new(old_text).with_context(|| format!("invalid regex: {old_text}"))?;
204 let mut count = 0usize;
205 let replaced = re
206 .replace_all(&content, |_caps: ®ex::Captures| {
207 count += 1;
208 new_text
209 })
210 .into_owned();
211 (replaced, count)
212 } else {
213 let count = content.matches(old_text).count();
214 let replaced = content.replace(old_text, new_text);
215 (replaced, count)
216 };
217 let evidence = match apply_full_write_with_evidence(project, relative_path, &result) {
218 Ok(ev) => ev,
219 Err(crate::edit_transaction::ApplyError::ApplyFailed {
220 source: _,
221 evidence,
222 }) => {
223 evidence
227 }
228 Err(other) => return Err(anyhow::Error::msg(other.to_string())),
229 };
230 Ok((result, count, evidence))
231}
232
233pub fn replace_symbol_body(
234 project: &ProjectRoot,
235 relative_path: &str,
236 symbol_name: &str,
237 name_path: Option<&str>,
238 new_body: &str,
239) -> Result<(String, ApplyEvidence)> {
240 let (start_byte, end_byte) =
241 crate::symbols::find_symbol_range(project, relative_path, symbol_name, name_path)?;
242 let resolved = project.resolve(relative_path)?;
243 let content = fs::read_to_string(&resolved)
244 .with_context(|| format!("failed to read {}", resolved.display()))?;
245 let bytes = content.as_bytes();
246 let mut buffer = Vec::with_capacity(bytes.len());
247 buffer.extend_from_slice(&bytes[..start_byte]);
248 buffer.extend_from_slice(new_body.as_bytes());
249 buffer.extend_from_slice(&bytes[end_byte..]);
250 let result =
251 String::from_utf8(buffer).with_context(|| "result is not valid UTF-8 after replacement")?;
252 let evidence = match apply_full_write_with_evidence(project, relative_path, &result) {
253 Ok(ev) => ev,
254 Err(crate::edit_transaction::ApplyError::ApplyFailed {
255 source: _,
256 evidence,
257 }) => {
258 evidence
262 }
263 Err(other) => return Err(anyhow::Error::msg(other.to_string())),
264 };
265 Ok((result, evidence))
266}
267
268pub fn insert_before_symbol(
269 project: &ProjectRoot,
270 relative_path: &str,
271 symbol_name: &str,
272 name_path: Option<&str>,
273 content_to_insert: &str,
274) -> Result<(String, ApplyEvidence)> {
275 let (start_byte, _) =
276 crate::symbols::find_symbol_range(project, relative_path, symbol_name, name_path)?;
277 let resolved = project.resolve(relative_path)?;
278 let content = fs::read_to_string(&resolved)
279 .with_context(|| format!("failed to read {}", resolved.display()))?;
280 let bytes = content.as_bytes();
281 let mut buffer = Vec::with_capacity(bytes.len() + content_to_insert.len());
282 buffer.extend_from_slice(&bytes[..start_byte]);
283 buffer.extend_from_slice(content_to_insert.as_bytes());
284 buffer.extend_from_slice(&bytes[start_byte..]);
285 let result =
286 String::from_utf8(buffer).with_context(|| "result is not valid UTF-8 after insertion")?;
287 let evidence = match apply_full_write_with_evidence(project, relative_path, &result) {
288 Ok(ev) => ev,
289 Err(crate::edit_transaction::ApplyError::ApplyFailed {
290 source: _,
291 evidence,
292 }) => {
293 evidence
297 }
298 Err(other) => return Err(anyhow::Error::msg(other.to_string())),
299 };
300 Ok((result, evidence))
301}
302
303pub fn insert_after_symbol(
304 project: &ProjectRoot,
305 relative_path: &str,
306 symbol_name: &str,
307 name_path: Option<&str>,
308 content_to_insert: &str,
309) -> Result<(String, ApplyEvidence)> {
310 let (_, end_byte) =
311 crate::symbols::find_symbol_range(project, relative_path, symbol_name, name_path)?;
312 let resolved = project.resolve(relative_path)?;
313 let content = fs::read_to_string(&resolved)
314 .with_context(|| format!("failed to read {}", resolved.display()))?;
315 let bytes = content.as_bytes();
316 let mut buffer = Vec::with_capacity(bytes.len() + content_to_insert.len());
317 buffer.extend_from_slice(&bytes[..end_byte]);
318 buffer.extend_from_slice(content_to_insert.as_bytes());
319 buffer.extend_from_slice(&bytes[end_byte..]);
320 let result =
321 String::from_utf8(buffer).with_context(|| "result is not valid UTF-8 after insertion")?;
322 let evidence = match apply_full_write_with_evidence(project, relative_path, &result) {
323 Ok(ev) => ev,
324 Err(crate::edit_transaction::ApplyError::ApplyFailed {
325 source: _,
326 evidence,
327 }) => {
328 evidence
332 }
333 Err(other) => return Err(anyhow::Error::msg(other.to_string())),
334 };
335 Ok((result, evidence))
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use crate::edit_transaction::ApplyStatus;
342 use crate::project::ProjectRoot;
343
344 fn make_project(dir: &std::path::Path) -> ProjectRoot {
345 ProjectRoot::new(dir.to_str().unwrap()).unwrap()
346 }
347
348 fn temp_dir_with_name(name: &str) -> std::path::PathBuf {
349 let dir = std::env::temp_dir().join(format!(
350 "codelens-writer-{}-{}-{}",
351 name,
352 std::process::id(),
353 std::time::SystemTime::now()
354 .duration_since(std::time::UNIX_EPOCH)
355 .unwrap()
356 .as_nanos()
357 ));
358 std::fs::create_dir_all(&dir).unwrap();
359 dir
360 }
361
362 #[test]
363 fn replace_lines_evidence_post_apply_hash_matches_disk() {
364 use sha2::{Digest, Sha256};
365
366 let dir = temp_dir_with_name("evidence");
367 let project = make_project(&dir);
368 std::fs::write(dir.join("doc.txt"), "line1\nline2\nline3\n").unwrap();
369
370 let (content, evidence) = replace_lines(&project, "doc.txt", 2, 3, "REPLACED\n").unwrap();
371 assert!(content.contains("REPLACED"));
372 assert_eq!(evidence.status, ApplyStatus::Applied);
373 assert_eq!(evidence.modified_files, 1);
374 assert_eq!(evidence.edit_count, 1);
375
376 let on_disk = std::fs::read(dir.join("doc.txt")).unwrap();
377 let mut hasher = Sha256::new();
378 hasher.update(&on_disk);
379 let mut hex = String::with_capacity(64);
380 for byte in hasher.finalize() {
381 use std::fmt::Write as _;
382 let _ = write!(hex, "{byte:02x}");
383 }
384 let evidence_hash = &evidence.file_hashes_after["doc.txt"].sha256;
385 assert_eq!(
386 evidence_hash, &hex,
387 "evidence post-apply hash must match disk content"
388 );
389 }
390}