llm_coding_tools_core/operations/
edit.rs1use crate::error::ToolError;
4use crate::fs;
5use crate::path::PathResolver;
6use thiserror::Error;
7
8#[derive(Debug, Error)]
10pub enum EditError {
11 #[error(transparent)]
13 Tool(#[from] ToolError),
14 #[error("old_string must not be empty")]
16 EmptyOldString,
17 #[error("old_string and new_string must be different")]
19 IdenticalStrings,
20 #[error("old_string not found in file content")]
22 NotFound,
23 #[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#[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}