vtcode_core/tools/file_ops/
write.rs1use super::FileOpsTool;
2use super::diff_preview::{build_diff_preview, diff_preview_error_skip, diff_preview_size_skip};
3mod chunked;
4mod fs_ops;
5use crate::config::constants::diff;
6use crate::config::constants::tools;
7use crate::tools::builder::ToolResponseBuilder;
8use crate::tools::edited_file_monitor::conflict_override_snapshot;
9use crate::tools::error_helpers::deserialize_tool_args;
10use crate::tools::traits::FileTool;
11use crate::tools::types::WriteInput;
12use crate::utils::file_utils::{ensure_dir_exists, read_file_with_context};
13use anyhow::{Context, Result, anyhow};
14use serde_json::{Value, json};
15use std::borrow::Cow;
16use std::future::Future;
17use std::io::ErrorKind;
18use tokio::io::AsyncWriteExt;
19
20const MAX_WRITE_BYTES: usize = 64_000;
21
22async fn write_text_file(path: &std::path::Path, content: &str) -> Result<()> {
23 let mut file = tokio::fs::OpenOptions::new()
24 .create(true)
25 .write(true)
26 .truncate(true)
27 .open(path)
28 .await
29 .with_context(|| format!("Failed to open file for writing: {}", path.display()))?;
30 file.write_all(content.as_bytes())
31 .await
32 .with_context(|| format!("Failed to write file content: {}", path.display()))?;
33 file.flush()
34 .await
35 .with_context(|| format!("Failed to flush file content: {}", path.display()))
36}
37
38async fn create_text_file(path: &std::path::Path, content: &str) -> Result<(), std::io::Error> {
39 let mut file = tokio::fs::OpenOptions::new()
40 .create_new(true)
41 .write(true)
42 .open(path)
43 .await?;
44 file.write_all(content.as_bytes()).await?;
45 file.flush().await
46}
47
48impl FileOpsTool {
49 pub fn write_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
53 self.write_file_internal(args, true)
54 }
55
56 pub(crate) async fn write_file_internal(
57 &self,
58 args: Value,
59 acquire_mutation: bool,
60 ) -> Result<Value> {
61 let input: WriteInput = deserialize_tool_args(&args, "write_file")?;
62 let override_snapshot = conflict_override_snapshot(&args);
63
64 let file_path = self.normalize_and_validate_user_path(&input.path).await?;
65
66 if self.should_exclude(&file_path).await {
67 return Err(anyhow!(
68 "Error: Path '{}' is excluded by .vtcodegitignore",
69 input.path
70 ));
71 }
72
73 let content_size = input.content.len();
74 if content_size > MAX_WRITE_BYTES {
75 return Err(anyhow!(
76 "Content exceeds safe write limit ({MAX_WRITE_BYTES} bytes, got {content_size} bytes). \
77 Use unified_exec with a shell heredoc to write large files, e.g. \
78 unified_exec(action='run', command=\"cat > '<path>' << '__VT_WRITE_EOF__'\\n<content>\\n__VT_WRITE_EOF__\")."
79 ));
80 }
81
82 let _mutation_lease = if acquire_mutation {
83 Some(self.edited_file_monitor.acquire_mutation(&file_path).await)
84 } else {
85 None
86 };
87
88 if let Some(parent) = file_path.parent() {
90 ensure_dir_exists(parent).await?;
91 }
92
93 let file_exists = tokio::fs::try_exists(&file_path).await?;
94
95 let mut existing_content: Option<String> = None;
96 let mut diff_preview: Option<Value> = None;
97
98 if file_exists {
99 match read_file_with_context(&file_path, "existing file content").await {
100 Ok(content) => existing_content = Some(content),
101 Err(error) => {
102 diff_preview = Some(diff_preview_error_skip(
103 "failed_to_read_existing_content",
104 Some(&error.to_string()),
105 ));
106 }
107 }
108 }
109
110 let effective_mode = if input.overwrite
111 && input.mode != "overwrite"
112 && input.mode != "fail_if_exists"
113 {
114 return Err(anyhow!(
115 "Conflicting parameters: overwrite=true but mode='{}'. Use mode='overwrite' or omit overwrite.",
116 input.mode
117 ));
118 } else if input.overwrite {
119 "overwrite"
120 } else {
121 input.mode.as_str()
122 };
123
124 if effective_mode == "skip_if_exists" && file_exists {
125 return Ok(ToolResponseBuilder::new(tools::WRITE_FILE)
126 .success()
127 .message("File already exists")
128 .field("skipped", json!(true))
129 .field("reason", json!("File already exists"))
130 .build_json());
131 }
132 if effective_mode == "fail_if_exists" && file_exists {
133 return Err(anyhow!(
134 "File '{}' exists. Use mode='overwrite' (or overwrite=true) to replace, or choose append/skip_if_exists.",
135 input.path
136 ));
137 }
138
139 let intended_content = match effective_mode {
140 "overwrite" => Some(input.content.clone()),
141 "append" => existing_content
142 .as_ref()
143 .map(|content| format!("{content}{}", input.content))
144 .or_else(|| Some(input.content.clone())),
145 "skip_if_exists" | "fail_if_exists" => Some(input.content.clone()),
146 _ => None,
147 };
148
149 if let Some(conflict) = self
150 .edited_file_monitor
151 .detect_conflict(
152 &file_path,
153 intended_content.clone(),
154 override_snapshot.clone(),
155 )
156 .await?
157 {
158 return Ok(conflict.to_tool_output(&self.workspace_root));
159 }
160
161 let final_written_content = match effective_mode {
162 "append" => intended_content
163 .clone()
164 .unwrap_or_else(|| input.content.clone()),
165 _ => input.content.clone(),
166 };
167
168 if matches!(effective_mode, "overwrite" | "append")
169 && let Some(conflict) = self
170 .edited_file_monitor
171 .detect_conflict(&file_path, intended_content.clone(), override_snapshot)
172 .await?
173 {
174 return Ok(conflict.to_tool_output(&self.workspace_root));
175 }
176
177 match effective_mode {
178 "overwrite" => {
179 write_text_file(&file_path, &input.content).await?;
180 }
181 "append" => {
182 let mut file = tokio::fs::OpenOptions::new()
183 .create(true)
184 .append(true)
185 .open(&file_path)
186 .await?;
187 file.write_all(input.content.as_bytes()).await?;
188 file.flush().await?;
189 }
190 "skip_if_exists" => {
191 if let Err(err) = create_text_file(&file_path, &input.content).await {
192 if err.kind() == ErrorKind::AlreadyExists {
193 return Ok(ToolResponseBuilder::new(tools::WRITE_FILE)
194 .success()
195 .message("File already exists")
196 .field("skipped", json!(true))
197 .field("reason", json!("File already exists"))
198 .build_json());
199 }
200 return Err(err).with_context(|| {
201 format!("Failed to create file content: {}", file_path.display())
202 });
203 }
204 }
205 "fail_if_exists" => {
206 if let Err(err) = create_text_file(&file_path, &input.content).await {
207 if err.kind() == ErrorKind::AlreadyExists {
208 return Err(anyhow!(
209 "File '{}' exists. Use mode='overwrite' (or overwrite=true) to replace, or choose append/skip_if_exists.",
210 input.path
211 ));
212 }
213 return Err(err).with_context(|| {
214 format!("Failed to create file content: {}", file_path.display())
215 });
216 }
217 }
218 _ => {
219 return Err(anyhow!(
220 "Error: Unsupported write mode '{}'. Allowed: overwrite, append, skip_if_exists, fail_if_exists.",
221 effective_mode
222 ));
223 }
224 }
225
226 self.log_write_operation(&file_path, content_size, false)
228 .await?;
229 if let Err(err) = self
230 .edited_file_monitor
231 .record_agent_write_text(&file_path, &final_written_content)
232 {
233 tracing::warn!(
234 path = %file_path.display(),
235 error = %err,
236 "Failed to refresh edited-file snapshot after write"
237 );
238 }
239
240 if diff_preview.is_none() {
241 let existing_snapshot = existing_content.as_deref();
242 let total_len = if effective_mode == "append" {
243 existing_snapshot
244 .map(|content| content.len())
245 .unwrap_or_default()
246 + input.content.len()
247 } else {
248 input.content.len()
249 };
250
251 if total_len > diff::MAX_PREVIEW_BYTES
252 || existing_snapshot
253 .map(|content| content.len() > diff::MAX_PREVIEW_BYTES)
254 .unwrap_or(false)
255 {
256 diff_preview = Some(diff_preview_size_skip());
257 } else {
258 let final_snapshot: Cow<'_, str> = if effective_mode == "append" {
259 if let Some(existing) = existing_snapshot {
260 Cow::Owned(format!("{existing}{}", input.content))
261 } else {
262 Cow::Borrowed(input.content.as_str())
263 }
264 } else {
265 Cow::Borrowed(input.content.as_str())
266 };
267
268 diff_preview = Some(build_diff_preview(
269 &input.path,
270 existing_snapshot,
271 final_snapshot.as_ref(),
272 ));
273 }
274 }
275
276 let mut builder = ToolResponseBuilder::new(tools::WRITE_FILE)
277 .success()
278 .message(format!(
279 "Successfully wrote file {}",
280 self.workspace_relative_display(&file_path)
281 ))
282 .field("path", json!(self.workspace_relative_display(&file_path)))
283 .field("mode", json!(effective_mode))
284 .field("bytes_written", json!(input.content.len()))
285 .field("file_existed", json!(file_exists));
286
287 if let Some(preview) = diff_preview {
288 builder = builder.field("diff_preview", preview);
289 }
290
291 Ok(builder.build_json())
292 }
293}