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;
18
19const MAX_WRITE_BYTES: usize = 64_000;
20
21async fn write_text_file(path: &std::path::Path, content: &str) -> Result<()> {
22 tokio::fs::write(path, content.as_bytes())
25 .await
26 .with_context(|| format!("Failed to write file content: {}", path.display()))
27}
28
29async fn create_text_file(path: &std::path::Path, content: &str) -> Result<(), std::io::Error> {
30 let path = path.to_path_buf();
33 let bytes = content.as_bytes().to_vec();
34 tokio::task::spawn_blocking(move || {
35 use std::io::Write;
36 let mut options = std::fs::OpenOptions::new();
37 options.create_new(true).write(true);
38 let mut file = options.open(&path)?;
39 file.write_all(&bytes)?;
40 file.flush()
41 })
42 .await
43 .map_err(|err| std::io::Error::other(format!("write task join failed: {err}")))?
44}
45
46impl FileOpsTool {
47 pub fn write_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
51 self.write_file_internal(args, true)
52 }
53
54 pub(crate) async fn write_file_internal(&self, args: Value, acquire_mutation: bool) -> Result<Value> {
55 let input: WriteInput = deserialize_tool_args(&args, "write_file")?;
56 let override_snapshot = conflict_override_snapshot(&args);
57
58 let file_path = self.normalize_and_validate_user_path(&input.path).await?;
59
60 if self.should_exclude(&file_path).await {
61 return Err(anyhow!("Error: Path '{}' is excluded by .vtcodegitignore", input.path));
62 }
63
64 let content_size = input.content.len();
65 if content_size > MAX_WRITE_BYTES {
66 return Err(anyhow!(
67 "Content exceeds safe write limit ({MAX_WRITE_BYTES} bytes, got {content_size} bytes). \
68 Use bounded apply_patch operations to write large files."
69 ));
70 }
71
72 let _mutation_lease = if acquire_mutation {
73 Some(self.edited_file_monitor.acquire_mutation(&file_path).await)
74 } else {
75 None
76 };
77
78 if let Some(parent) = file_path.parent() {
80 ensure_dir_exists(parent).await?;
81 }
82
83 let file_exists = tokio::fs::try_exists(&file_path).await?;
84
85 let mut existing_content: Option<String> = None;
86 let mut diff_preview: Option<Value> = None;
87
88 if file_exists {
89 match read_file_with_context(&file_path, "existing file content").await {
90 Ok(content) => existing_content = Some(content),
91 Err(error) => {
92 diff_preview =
93 Some(diff_preview_error_skip("failed_to_read_existing_content", Some(&error.to_string())));
94 }
95 }
96 }
97
98 let effective_mode = if input.overwrite && input.mode != "overwrite" && input.mode != "fail_if_exists" {
99 return Err(anyhow!(
100 "Conflicting parameters: overwrite=true but mode='{}'. Use mode='overwrite' or omit overwrite.",
101 input.mode
102 ));
103 } else if input.overwrite {
104 "overwrite"
105 } else {
106 input.mode.as_str()
107 };
108
109 if effective_mode == "skip_if_exists" && file_exists {
110 return Ok(ToolResponseBuilder::new(tools::WRITE_FILE)
111 .success()
112 .message("File already exists")
113 .field("skipped", json!(true))
114 .field("reason", json!("File already exists"))
115 .field("diff", json!([]))
116 .build_json());
117 }
118 if effective_mode == "fail_if_exists" && file_exists {
119 return Err(anyhow!(
120 "File '{}' exists. Use mode='overwrite' (or overwrite=true) to replace, or choose append/skip_if_exists.",
121 input.path
122 ));
123 }
124
125 if acquire_mutation && let Some(content) = existing_content.as_deref() {
126 if effective_mode == "append" {
127 let line_count = content.lines().count();
128 self.edited_file_monitor.source_inspection().require(
129 &file_path,
130 content,
131 std::slice::from_ref(&(line_count.saturating_sub(1)..line_count)),
132 )?;
133 } else {
134 self.edited_file_monitor.source_inspection().require_full(&file_path, content)?;
135 }
136 } else if file_exists && existing_content.is_none() {
137 return Err(anyhow!("Existing content must be readable before replacement"));
138 }
139
140 let intended_content = match effective_mode {
141 "overwrite" => Some(input.content.clone()),
142 "append" => existing_content
143 .as_ref()
144 .map(|content| format!("{content}{}", input.content))
145 .or_else(|| Some(input.content.clone())),
146 "skip_if_exists" | "fail_if_exists" => Some(input.content.clone()),
147 _ => None,
148 };
149
150 if let Some(conflict) = self
151 .edited_file_monitor
152 .detect_conflict(&file_path, intended_content.clone(), override_snapshot.clone())
153 .await?
154 {
155 return Ok(conflict.to_tool_output(&self.workspace_root));
156 }
157
158 let final_written_content = match effective_mode {
159 "append" => intended_content.clone().unwrap_or_else(|| input.content.clone()),
160 _ => input.content.clone(),
161 };
162
163 if matches!(effective_mode, "overwrite" | "append")
164 && let Some(conflict) = self
165 .edited_file_monitor
166 .detect_conflict(&file_path, intended_content.clone(), override_snapshot)
167 .await?
168 {
169 return Ok(conflict.to_tool_output(&self.workspace_root));
170 }
171
172 match effective_mode {
173 "overwrite" => {
174 write_text_file(&file_path, &input.content).await?;
175 }
176 "append" => {
177 let path = file_path.clone();
180 let bytes = input.content.as_bytes().to_vec();
181 tokio::task::spawn_blocking(move || {
182 use std::io::Write;
183 let mut file = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
184 file.write_all(&bytes)?;
185 file.flush()
186 })
187 .await
188 .map_err(|err| anyhow!("append task join failed: {err}"))?
189 .with_context(|| format!("Failed to append file content: {}", file_path.display()))?;
190 }
191 "skip_if_exists" => {
192 if let Err(err) = create_text_file(&file_path, &input.content).await {
193 if err.kind() == ErrorKind::AlreadyExists {
194 return Ok(ToolResponseBuilder::new(tools::WRITE_FILE)
195 .success()
196 .message("File already exists")
197 .field("skipped", json!(true))
198 .field("reason", json!("File already exists"))
199 .field("diff", json!([]))
200 .build_json());
201 }
202 return Err(err).with_context(|| format!("Failed to create file content: {}", file_path.display()));
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(|| format!("Failed to create file content: {}", file_path.display()));
214 }
215 }
216 _ => {
217 return Err(anyhow!(
218 "Error: Unsupported write mode '{effective_mode}'. Allowed: overwrite, append, skip_if_exists, fail_if_exists."
219 ));
220 }
221 }
222
223 if acquire_mutation {
224 let support = crate::tools::source_inspection::call_key(tools::WRITE_FILE, &args);
225 if let Some(before) = &existing_content {
226 self.edited_file_monitor.source_inspection().record_edit(
227 &file_path,
228 before,
229 &final_written_content,
230 std::slice::from_ref(&if effective_mode == "append" {
231 let removed = usize::from(!before.is_empty() && !before.ends_with('\n'));
232 let start = before.lines().count().saturating_sub(removed);
233 crate::tools::source_inspection::mutations::InspectedReplacement {
234 start,
235 removed,
236 added: final_written_content.lines().count().saturating_sub(start),
237 }
238 } else {
239 crate::tools::source_inspection::mutations::InspectedReplacement {
240 start: 0,
241 removed: before.lines().count(),
242 added: final_written_content.lines().count(),
243 }
244 }),
245 support,
246 );
247 } else {
248 self.edited_file_monitor.source_inspection().record_created(
249 &file_path,
250 &final_written_content,
251 support,
252 );
253 }
254 }
255
256 self.log_write_operation(&file_path, content_size, false).await?;
258 if let Err(err) = self
259 .edited_file_monitor
260 .record_agent_write_text(&file_path, &final_written_content)
261 {
262 tracing::warn!(
263 path = %file_path.display(),
264 error = %err,
265 "Failed to refresh edited-file snapshot after write"
266 );
267 }
268
269 if diff_preview.is_none() {
270 let existing_snapshot = existing_content.as_deref();
271 let total_len = if effective_mode == "append" {
272 existing_snapshot.map(|content| content.len()).unwrap_or_default() + input.content.len()
273 } else {
274 input.content.len()
275 };
276
277 if total_len > diff::MAX_PREVIEW_BYTES
278 || existing_snapshot
279 .map(|content| content.len() > diff::MAX_PREVIEW_BYTES)
280 .unwrap_or(false)
281 {
282 diff_preview = Some(diff_preview_size_skip());
283 } else {
284 let final_snapshot: Cow<'_, str> = if effective_mode == "append" {
285 if let Some(existing) = existing_snapshot {
286 Cow::Owned(format!("{existing}{}", input.content))
287 } else {
288 Cow::Borrowed(input.content.as_str())
289 }
290 } else {
291 Cow::Borrowed(input.content.as_str())
292 };
293
294 diff_preview = Some(build_diff_preview(&input.path, existing_snapshot, final_snapshot.as_ref()));
295 }
296 }
297
298 let mut builder = ToolResponseBuilder::new(tools::WRITE_FILE)
299 .success()
300 .message(format!("Successfully wrote file {}", self.workspace_relative_display(&file_path)))
301 .field("path", json!(self.workspace_relative_display(&file_path)))
302 .field("mode", json!(effective_mode))
303 .field("bytes_written", json!(input.content.len()))
304 .field("file_existed", json!(file_exists));
305
306 if let Some(preview) = diff_preview {
307 let mut canonical_preview = preview.clone();
308 if let Some(fields) = canonical_preview.as_object_mut() {
309 fields.insert("path".to_string(), json!(self.workspace_relative_display(&file_path)));
310 fields.insert("operation".to_string(), json!(if file_exists { "updated" } else { "created" }));
311 }
312 builder = builder
313 .field("created", json!(!file_exists))
314 .field("diff_preview", preview)
315 .field("diff", json!([canonical_preview]));
316 }
317
318 Ok(builder.build_json())
319 }
320}