llm_coding_tools_core/operations/
edit.rs

1//! File editing operation with exact string replacement.
2
3use crate::error::ToolError;
4use crate::fs;
5use crate::path::PathResolver;
6use thiserror::Error;
7
8/// Errors specific to edit operations.
9#[derive(Debug, Error)]
10pub enum EditError {
11    /// I/O or path validation error.
12    #[error(transparent)]
13    Tool(#[from] ToolError),
14    /// The old_string parameter was empty.
15    #[error("old_string must not be empty")]
16    EmptyOldString,
17    /// The old_string and new_string are identical.
18    #[error("old_string and new_string must be different")]
19    IdenticalStrings,
20    /// The old_string was not found in the file.
21    #[error("old_string not found in file content")]
22    NotFound,
23    /// Multiple matches found when replace_all is false.
24    #[error(
25        "oldString found {0} times and requires more code context to uniquely identify the intended match"
26    )]
27    AmbiguousMatch(usize),
28}
29
30impl From<std::io::Error> for EditError {
31    fn from(e: std::io::Error) -> Self {
32        EditError::Tool(ToolError::from(e))
33    }
34}
35
36/// Performs exact string replacement in a file.
37///
38/// Returns success message with replacement count.
39#[maybe_async::maybe_async]
40pub async fn edit_file<R: PathResolver>(
41    resolver: &R,
42    file_path: &str,
43    old_string: &str,
44    new_string: &str,
45    replace_all: bool,
46) -> Result<String, EditError> {
47    if old_string.is_empty() {
48        return Err(EditError::EmptyOldString);
49    }
50    if old_string == new_string {
51        return Err(EditError::IdenticalStrings);
52    }
53
54    let path = resolver.resolve(file_path)?;
55    let content = fs::read_to_string(&path).await?;
56
57    let count = content.matches(old_string).count();
58
59    if count == 0 {
60        return Err(EditError::NotFound);
61    }
62
63    if !replace_all && count > 1 {
64        return Err(EditError::AmbiguousMatch(count));
65    }
66
67    let new_content = if replace_all {
68        content.replace(old_string, new_string)
69    } else {
70        content.replacen(old_string, new_string, 1)
71    };
72
73    fs::write(&path, &new_content).await?;
74
75    Ok(format!("Successfully replaced {} occurrence(s)", count))
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use crate::path::AbsolutePathResolver;
82    use std::io::Write;
83    use tempfile::NamedTempFile;
84
85    fn create_temp_file(content: &str) -> NamedTempFile {
86        let mut file = NamedTempFile::new().unwrap();
87        file.write_all(content.as_bytes()).unwrap();
88        file.flush().unwrap();
89        file
90    }
91
92    #[maybe_async::test(feature = "blocking", async(not(feature = "blocking"), tokio::test))]
93    async fn single_replacement_succeeds() {
94        let file = create_temp_file("hello world");
95        let resolver = AbsolutePathResolver;
96
97        let result = edit_file(
98            &resolver,
99            file.path().to_str().unwrap(),
100            "world",
101            "rust",
102            false,
103        )
104        .await
105        .unwrap();
106
107        assert!(result.contains("1 occurrence"));
108        let content = std::fs::read_to_string(file.path()).unwrap();
109        assert_eq!(content, "hello rust");
110    }
111
112    #[maybe_async::test(feature = "blocking", async(not(feature = "blocking"), tokio::test))]
113    async fn not_found_returns_error() {
114        let file = create_temp_file("hello world");
115        let resolver = AbsolutePathResolver;
116
117        let err = edit_file(
118            &resolver,
119            file.path().to_str().unwrap(),
120            "missing",
121            "x",
122            false,
123        )
124        .await
125        .unwrap_err();
126        assert!(matches!(err, EditError::NotFound));
127    }
128}