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 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        // Create parent directories if needed
80        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                // One blocking-pool hop via `std::fs` (tokio `fs` tuning for
164                // open + write + flush sequences instead of 3 async hops).
165                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        // Log write operation
210        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}