Skip to main content

vtcode_core/tools/file_ops/
write.rs

1use 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    // Single `spawn_blocking` write (tokio `fs` tuning: prefer `write` over
23    // `open` + `write_all` + `flush`, which costs 3 blocking-pool hops).
24    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    // Preserve `create_new` semantics in one blocking-pool hop: `std::fs`
31    // inside `spawn_blocking` (tokio `fs` tuning for multi-step sequences).
32    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    /// Write file with various modes and chunking support for large content.
48    /// Inline-delegating wrapper that returns the inner future directly to
49    /// avoid an extra coroutine state machine (audit section 16).
50    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        // Create parent directories if needed
79        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                // One blocking-pool hop via `std::fs` (tokio `fs` tuning for
178                // open + write + flush sequences instead of 3 async hops).
179                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        // Log write operation
257        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}