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 exec_command with a shell heredoc to write large files, e.g. \
69 exec_command(cmd=\"cat > '<path>' << '__VT_WRITE_EOF__'\\n<content>\\n__VT_WRITE_EOF__\")."
70 ));
71 }
72
73 let _mutation_lease = if acquire_mutation {
74 Some(self.edited_file_monitor.acquire_mutation(&file_path).await)
75 } else {
76 None
77 };
78
79 if let Some(parent) = file_path.parent() {
81 ensure_dir_exists(parent).await?;
82 }
83
84 let file_exists = tokio::fs::try_exists(&file_path).await?;
85
86 let mut existing_content: Option<String> = None;
87 let mut diff_preview: Option<Value> = None;
88
89 if file_exists {
90 match read_file_with_context(&file_path, "existing file content").await {
91 Ok(content) => existing_content = Some(content),
92 Err(error) => {
93 diff_preview =
94 Some(diff_preview_error_skip("failed_to_read_existing_content", Some(&error.to_string())));
95 }
96 }
97 }
98
99 let effective_mode = if input.overwrite && input.mode != "overwrite" && input.mode != "fail_if_exists" {
100 return Err(anyhow!(
101 "Conflicting parameters: overwrite=true but mode='{}'. Use mode='overwrite' or omit overwrite.",
102 input.mode
103 ));
104 } else if input.overwrite {
105 "overwrite"
106 } else {
107 input.mode.as_str()
108 };
109
110 if effective_mode == "skip_if_exists" && file_exists {
111 return Ok(ToolResponseBuilder::new(tools::WRITE_FILE)
112 .success()
113 .message("File already exists")
114 .field("skipped", json!(true))
115 .field("reason", json!("File already exists"))
116 .field("diff", json!([]))
117 .build_json());
118 }
119 if effective_mode == "fail_if_exists" && file_exists {
120 return Err(anyhow!(
121 "File '{}' exists. Use mode='overwrite' (or overwrite=true) to replace, or choose append/skip_if_exists.",
122 input.path
123 ));
124 }
125
126 let intended_content = match effective_mode {
127 "overwrite" => Some(input.content.clone()),
128 "append" => existing_content
129 .as_ref()
130 .map(|content| format!("{content}{}", input.content))
131 .or_else(|| Some(input.content.clone())),
132 "skip_if_exists" | "fail_if_exists" => Some(input.content.clone()),
133 _ => None,
134 };
135
136 if let Some(conflict) = self
137 .edited_file_monitor
138 .detect_conflict(&file_path, intended_content.clone(), override_snapshot.clone())
139 .await?
140 {
141 return Ok(conflict.to_tool_output(&self.workspace_root));
142 }
143
144 let final_written_content = match effective_mode {
145 "append" => intended_content.clone().unwrap_or_else(|| input.content.clone()),
146 _ => input.content.clone(),
147 };
148
149 if matches!(effective_mode, "overwrite" | "append")
150 && let Some(conflict) = self
151 .edited_file_monitor
152 .detect_conflict(&file_path, intended_content.clone(), override_snapshot)
153 .await?
154 {
155 return Ok(conflict.to_tool_output(&self.workspace_root));
156 }
157
158 match effective_mode {
159 "overwrite" => {
160 write_text_file(&file_path, &input.content).await?;
161 }
162 "append" => {
163 let path = file_path.clone();
166 let bytes = input.content.as_bytes().to_vec();
167 tokio::task::spawn_blocking(move || {
168 use std::io::Write;
169 let mut file = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
170 file.write_all(&bytes)?;
171 file.flush()
172 })
173 .await
174 .map_err(|err| anyhow!("append task join failed: {err}"))?
175 .with_context(|| format!("Failed to append file content: {}", file_path.display()))?;
176 }
177 "skip_if_exists" => {
178 if let Err(err) = create_text_file(&file_path, &input.content).await {
179 if err.kind() == ErrorKind::AlreadyExists {
180 return Ok(ToolResponseBuilder::new(tools::WRITE_FILE)
181 .success()
182 .message("File already exists")
183 .field("skipped", json!(true))
184 .field("reason", json!("File already exists"))
185 .field("diff", json!([]))
186 .build_json());
187 }
188 return Err(err).with_context(|| format!("Failed to create file content: {}", file_path.display()));
189 }
190 }
191 "fail_if_exists" => {
192 if let Err(err) = create_text_file(&file_path, &input.content).await {
193 if err.kind() == ErrorKind::AlreadyExists {
194 return Err(anyhow!(
195 "File '{}' exists. Use mode='overwrite' (or overwrite=true) to replace, or choose append/skip_if_exists.",
196 input.path
197 ));
198 }
199 return Err(err).with_context(|| format!("Failed to create file content: {}", file_path.display()));
200 }
201 }
202 _ => {
203 return Err(anyhow!(
204 "Error: Unsupported write mode '{effective_mode}'. Allowed: overwrite, append, skip_if_exists, fail_if_exists."
205 ));
206 }
207 }
208
209 self.log_write_operation(&file_path, content_size, false).await?;
211 if let Err(err) = self
212 .edited_file_monitor
213 .record_agent_write_text(&file_path, &final_written_content)
214 {
215 tracing::warn!(
216 path = %file_path.display(),
217 error = %err,
218 "Failed to refresh edited-file snapshot after write"
219 );
220 }
221
222 if diff_preview.is_none() {
223 let existing_snapshot = existing_content.as_deref();
224 let total_len = if effective_mode == "append" {
225 existing_snapshot.map(|content| content.len()).unwrap_or_default() + input.content.len()
226 } else {
227 input.content.len()
228 };
229
230 if total_len > diff::MAX_PREVIEW_BYTES
231 || existing_snapshot
232 .map(|content| content.len() > diff::MAX_PREVIEW_BYTES)
233 .unwrap_or(false)
234 {
235 diff_preview = Some(diff_preview_size_skip());
236 } else {
237 let final_snapshot: Cow<'_, str> = if effective_mode == "append" {
238 if let Some(existing) = existing_snapshot {
239 Cow::Owned(format!("{existing}{}", input.content))
240 } else {
241 Cow::Borrowed(input.content.as_str())
242 }
243 } else {
244 Cow::Borrowed(input.content.as_str())
245 };
246
247 diff_preview = Some(build_diff_preview(&input.path, existing_snapshot, final_snapshot.as_ref()));
248 }
249 }
250
251 let mut builder = ToolResponseBuilder::new(tools::WRITE_FILE)
252 .success()
253 .message(format!("Successfully wrote file {}", self.workspace_relative_display(&file_path)))
254 .field("path", json!(self.workspace_relative_display(&file_path)))
255 .field("mode", json!(effective_mode))
256 .field("bytes_written", json!(input.content.len()))
257 .field("file_existed", json!(file_exists));
258
259 if let Some(preview) = diff_preview {
260 let mut canonical_preview = preview.clone();
261 if let Some(fields) = canonical_preview.as_object_mut() {
262 fields.insert("path".to_string(), json!(self.workspace_relative_display(&file_path)));
263 fields.insert("operation".to_string(), json!(if file_exists { "updated" } else { "created" }));
264 }
265 builder = builder
266 .field("created", json!(!file_exists))
267 .field("diff_preview", preview)
268 .field("diff", json!([canonical_preview]));
269 }
270
271 Ok(builder.build_json())
272 }
273}