Skip to main content

deepseek_rust_cli/tools/file_io/
read_write.rs

1use 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    // Try exact first
58    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    // Try normalized whitespace
65    let normalized_old = old_text.split_whitespace().collect::<Vec<_>>().join(" ");
66    let lines: Vec<&str> = content.lines().collect();
67
68    // Simple block matching
69    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    // Find a sequence of lines that match (after normalization)
75    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    // Remove trailing empty lines
114    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    // Sort edits by start_line in descending order so that changing content size doesn't shift the
146    // indices of subsequent edits
147    let mut sorted_edits = edits;
148    sorted_edits.sort_by_key(|b| std::cmp::Reverse(b.start_line));
149
150    // Verify no overlapping edits
151    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; // inclusive
177
178        // Handle completely empty file insertion
179        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        // Handle appending past the last line
190        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            // Retrieve current lines
216            let current_chunk = lines[start_idx..=actual_end_idx].join("\n");
217
218            // Fuzzy compare target content and current chunk
219            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        // Replacement lines
238        let replacement_lines: Vec<String> = edit
239            .replacement_content
240            .lines()
241            .map(|s| s.to_string())
242            .collect();
243
244        // Splice replacement content
245        lines.splice(start_idx..=actual_end_idx, replacement_lines);
246    }
247
248    // Maintain ending newline if present originally
249    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    // Skip headers until we see a hunk starting with @@
275    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            // Parse @@ -old_start,old_count +new_start,new_count @@
293            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                    // Deleted line, skip
317                } 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                    // Skip \ No newline at end of file
326                } 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    // Sort hunks by old_start in descending order to avoid line shifting problems
340    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}