deepseek_rust_cli/tools/file_io/
read_write.rs1use anyhow::Result;
2use tokio::fs;
3
4use crate::tools::base::validate_path;
5
6pub async fn read_local_file(
7 path: &str,
8 start: Option<usize>,
9 end: Option<usize>,
10) -> Result<String> {
11 let p = validate_path(path)?;
12 let content = fs::read_to_string(p).await?;
13 let lines: Vec<&str> = content.lines().collect();
14
15 if lines.is_empty() {
16 return Ok(String::new());
17 }
18
19 let s = start.unwrap_or(1).saturating_sub(1);
20 let mut e = end.unwrap_or(lines.len());
21
22 if s >= lines.len() {
23 return Ok(String::new());
24 }
25
26 if e > lines.len() {
27 e = lines.len();
28 }
29 if e < s {
30 e = s;
31 }
32
33 Ok(lines[s..e].join("\n"))
34}
35
36pub async fn write_local_file(path: &str, content: &str) -> Result<()> {
37 let p = validate_path(path)?;
38 if let Some(parent) = p.parent() {
39 fs::create_dir_all(parent).await?;
40 }
41 fs::write(p, content).await?;
42 Ok(())
43}
44
45pub async fn replace_text_in_file(path: &str, old_text: &str, new_text: &str) -> Result<()> {
46 let p = validate_path(path)?;
47 let content = fs::read_to_string(&p).await?;
48 let new_content = content.replace(old_text, new_text);
49 fs::write(p, new_content).await?;
50 Ok(())
51}
52
53pub async fn fuzzy_replace_in_file(path: &str, old_text: &str, new_text: &str) -> Result<String> {
54 let p = validate_path(path)?;
55 let content = fs::read_to_string(&p).await?;
56
57 if content.contains(old_text) {
59 let new_content = content.replace(old_text, new_text);
60 fs::write(p, new_content).await?;
61 return Ok("Text replaced successfully (exact match).".to_string());
62 }
63
64 let normalized_old = old_text.split_whitespace().collect::<Vec<_>>().join(" ");
66 let lines: Vec<&str> = content.lines().collect();
67
68 let old_lines: Vec<&str> = old_text.lines().collect();
70 if old_lines.is_empty() {
71 return Err(anyhow::anyhow!("Old text is empty"));
72 }
73
74 for i in 0..=lines.len().saturating_sub(old_lines.len()) {
76 let window = &lines[i..i + old_lines.len()];
77 let window_normalized = window
78 .join("\n")
79 .split_whitespace()
80 .collect::<Vec<_>>()
81 .join(" ");
82
83 if window_normalized == normalized_old {
84 let mut new_lines = lines.iter().map(|s| s.to_string()).collect::<Vec<_>>();
85 new_lines.splice(i..i + old_lines.len(), vec![new_text.to_string()]);
86 let line_ending = if content.contains("\r\n") {
87 "\r\n"
88 } else {
89 "\n"
90 };
91 fs::write(p, new_lines.join(line_ending)).await?;
92 return Ok("Text replaced successfully (fuzzy match).".to_string());
93 }
94 }
95
96 Err(anyhow::anyhow!(
97 "Could not find a match for the provided text, even with fuzzy matching."
98 ))
99}
100
101pub async fn cleanup_file(path: &str) -> Result<String> {
102 let p = validate_path(path)?;
103 let content = fs::read_to_string(&p).await?;
104 let mut cleaned_lines = Vec::new();
105
106 for line in content.lines() {
107 let trimmed = line.trim_end();
108 if !trimmed.is_empty() || !cleaned_lines.is_empty() {
109 cleaned_lines.push(trimmed);
110 }
111 }
112
113 while let Some(last) = cleaned_lines.last() {
115 if last.is_empty() {
116 cleaned_lines.pop();
117 } else {
118 break;
119 }
120 }
121
122 let line_ending = if content.contains("\r\n") {
123 "\r\n"
124 } else {
125 "\n"
126 };
127 let cleaned_content = cleaned_lines.join(line_ending);
128 fs::write(p, &cleaned_content).await?;
129 Ok("File cleaned up (trailing spaces removed, line endings normalized).".to_string())
130}
131
132#[derive(Debug, Clone, serde::Deserialize)]
133pub struct LineEdit {
134 pub start_line: usize,
135 pub end_line: usize,
136 pub replacement_content: String,
137 pub target_content: Option<String>,
138}
139
140pub async fn edit_file_by_lines(path: &str, edits: Vec<LineEdit>) -> Result<String> {
141 let p = validate_path(path)?;
142 let content = fs::read_to_string(&p).await?;
143 let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
144
145 let mut sorted_edits = edits;
148 sorted_edits.sort_by_key(|b| std::cmp::Reverse(b.start_line));
149
150 for i in 0..sorted_edits.len().saturating_sub(1) {
152 if sorted_edits[i + 1].end_line >= sorted_edits[i].start_line {
153 anyhow::bail!(
154 "Overlapping edits detected: edit at {}-{} overlaps with edit at {}-{}",
155 sorted_edits[i + 1].start_line,
156 sorted_edits[i + 1].end_line,
157 sorted_edits[i].start_line,
158 sorted_edits[i].end_line
159 );
160 }
161 }
162
163 for edit in sorted_edits {
164 if edit.start_line == 0 {
165 anyhow::bail!("Line numbers are 1-indexed; start_line cannot be 0");
166 }
167 if edit.end_line < edit.start_line {
168 anyhow::bail!(
169 "end_line ({}) cannot be less than start_line ({})",
170 edit.end_line,
171 edit.start_line
172 );
173 }
174
175 let start_idx = edit.start_line - 1;
176 let end_idx = edit.end_line - 1; if lines.is_empty() && edit.start_line == 1 {
180 let replacement_lines: Vec<String> = edit
181 .replacement_content
182 .lines()
183 .map(|s| s.to_string())
184 .collect();
185 lines = replacement_lines;
186 continue;
187 }
188
189 if start_idx == lines.len() {
191 let replacement_lines: Vec<String> = edit
192 .replacement_content
193 .lines()
194 .map(|s| s.to_string())
195 .collect();
196 lines.extend(replacement_lines);
197 continue;
198 }
199
200 if start_idx >= lines.len() {
201 anyhow::bail!(
202 "start_line ({}) is out of bounds (file has {} lines)",
203 edit.start_line,
204 lines.len()
205 );
206 }
207
208 let actual_end_idx = if end_idx >= lines.len() {
209 lines.len() - 1
210 } else {
211 end_idx
212 };
213
214 if let Some(target) = &edit.target_content {
215 let current_chunk = lines[start_idx..=actual_end_idx].join("\n");
217
218 let norm_target: String = target.split_whitespace().collect::<Vec<_>>().join(" ");
220 let norm_current: String = current_chunk
221 .split_whitespace()
222 .collect::<Vec<_>>()
223 .join(" ");
224
225 if norm_target != norm_current {
226 anyhow::bail!(
227 "Target content verification failed at lines {}-{}.\nExpected (normalized): \
228 {}\nFound (normalized): {}",
229 edit.start_line,
230 edit.end_line,
231 norm_target,
232 norm_current
233 );
234 }
235 }
236
237 let replacement_lines: Vec<String> = edit
239 .replacement_content
240 .lines()
241 .map(|s| s.to_string())
242 .collect();
243
244 lines.splice(start_idx..=actual_end_idx, replacement_lines);
246 }
247
248 let line_ending = if content.contains("\r\n") {
250 "\r\n"
251 } else {
252 "\n"
253 };
254 let mut new_content = lines.join(line_ending);
255 if content.ends_with('\n') && !new_content.ends_with('\n') {
256 if line_ending == "\r\n" {
257 new_content.push_str("\r\n");
258 } else {
259 new_content.push('\n');
260 }
261 }
262
263 fs::write(&p, new_content).await?;
264 Ok("File successfully edited by lines.".to_string())
265}
266
267pub async fn apply_diff_patch(path: &str, patch_content: &str) -> Result<String> {
268 let p = validate_path(path)?;
269 let content = fs::read_to_string(&p).await?;
270 let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
271
272 let mut patch_lines = patch_content.lines().peekable();
273
274 while let Some(&line) = patch_lines.peek() {
276 if line.starts_with("@@") {
277 break;
278 }
279 patch_lines.next();
280 }
281
282 struct Hunk {
283 old_start: usize,
284 old_count: usize,
285 new_lines: Vec<String>,
286 }
287
288 let mut hunks = Vec::new();
289
290 while let Some(line) = patch_lines.next() {
291 if line.starts_with("@@") {
292 let parts: Vec<&str> = line.split_whitespace().collect();
294 if parts.len() < 3 {
295 anyhow::bail!("Invalid hunk header: {}", line);
296 }
297 let old_part = parts[1].trim_start_matches('-');
298 let old_subparts: Vec<&str> = old_part.split(',').collect();
299 let old_start: usize = old_subparts[0].parse()?;
300 let old_count: usize = if old_subparts.len() > 1 {
301 old_subparts[1].parse()?
302 } else {
303 1
304 };
305
306 let mut new_lines = Vec::new();
307
308 while let Some(&p_line) = patch_lines.peek() {
309 if p_line.starts_with("@@") {
310 break;
311 }
312 let p_line = patch_lines.next().unwrap();
313 if let Some(stripped) = p_line.strip_prefix('+') {
314 new_lines.push(stripped.to_string());
315 } else if p_line.starts_with('-') {
316 } else if p_line.starts_with(' ') || p_line.is_empty() {
318 let content_line = if let Some(stripped) = p_line.strip_prefix(' ') {
319 stripped
320 } else {
321 p_line
322 };
323 new_lines.push(content_line.to_string());
324 } else if p_line.starts_with('\\') {
325 } else {
327 new_lines.push(p_line.to_string());
328 }
329 }
330
331 hunks.push(Hunk {
332 old_start,
333 old_count,
334 new_lines,
335 });
336 }
337 }
338
339 hunks.sort_by_key(|b| std::cmp::Reverse(b.old_start));
341
342 for hunk in hunks {
343 let start_idx = if hunk.old_start == 0 {
344 0
345 } else {
346 hunk.old_start - 1
347 };
348 let end_idx = start_idx + hunk.old_count;
349
350 if start_idx > lines.len() {
351 anyhow::bail!(
352 "Hunk start line ({}) is out of bounds (file has {} lines)",
353 hunk.old_start,
354 lines.len()
355 );
356 }
357
358 let _actual_end_idx = if end_idx > lines.len() {
359 lines.len()
360 } else {
361 end_idx
362 };
363
364 lines.splice(start_idx.._actual_end_idx, hunk.new_lines);
365 }
366
367 let line_ending = if content.contains("\r\n") {
368 "\r\n"
369 } else {
370 "\n"
371 };
372 let mut new_content = lines.join(line_ending);
373 if content.ends_with('\n') && !new_content.ends_with('\n') {
374 if line_ending == "\r\n" {
375 new_content.push_str("\r\n");
376 } else {
377 new_content.push('\n');
378 }
379 }
380
381 fs::write(&p, new_content).await?;
382 Ok("Patch successfully applied.".to_string())
383}
384
385#[cfg(test)]
386mod tests {
387 use std::fs;
388
389 use tempfile::TempDir;
390
391 use super::*;
392
393 fn tempdir_in_cwd() -> TempDir {
394 TempDir::new_in(".").expect("Failed to create temp dir in CWD")
395 }
396
397 #[tokio::test]
398 async fn test_fuzzy_replace_exact() {
399 let dir = tempdir_in_cwd();
400 let file_path = dir.path().join("test.txt");
401 fs::write(&file_path, "hello world\nthis is a test").unwrap();
402
403 let path_str = file_path.to_str().unwrap();
404 let res = fuzzy_replace_in_file(path_str, "hello world", "bye world").await;
405
406 assert!(res.is_ok());
407 let content = fs::read_to_string(&file_path).unwrap();
408 assert_eq!(content, "bye world\nthis is a test");
409 }
410
411 #[tokio::test]
412 async fn test_fuzzy_replace_whitespace() {
413 let dir = tempdir_in_cwd();
414 let file_path = dir.path().join("test.txt");
415 fs::write(&file_path, "hello world\nthis is a test").unwrap();
416
417 let path_str = file_path.to_str().unwrap();
418 let res = fuzzy_replace_in_file(path_str, "hello world", "bye world").await;
419
420 assert!(res.is_ok());
421 let content = fs::read_to_string(&file_path).unwrap();
422 assert_eq!(content, "bye world\nthis is a test");
423 }
424
425 #[tokio::test]
426 async fn test_read_local_file() {
427 let dir = tempdir_in_cwd();
428 let file_path = dir.path().join("test.txt");
429 fs::write(&file_path, "line 1\nline 2\nline 3\nline 4").unwrap();
430
431 let path_str = file_path.to_str().unwrap();
432 let content = read_local_file(path_str, Some(2), Some(3)).await.unwrap();
433 assert_eq!(content, "line 2\nline 3");
434 }
435}